Deploy test site / deploy (push) Successful in 53s
New static page docs/artist-order.html (test site /demos/artist-order/) walking the artist platform flow end to end: - record configurator (format, speed, discs, weight, colour, packaging, extras, mastering, test pressings) with a live mock-up - track list or dropped masters read in the browser (WAV/FLAC/AIFF headers, sample peak and clipping), side length against the format, jacket and label preflight (size, dpi, CMYK), rights and gate 1 - itemised offer (per title / per copy, pass-through jacket), price per copy against quantity, lead time from the production timeline - direct order or fan-funded campaign: goal = max(minimum run, break-even), what the artist keeps, the money rules, a simulated campaign (sells out / just makes it / falls short) with refund or go-ahead-at-minimum, and the production tracker with the three gates and the fan update feed Price list demo-0.1 splits the doc's 38,000 THB per title and 132 THB per copy into lines (estimates, each listed with its basis); defaults reproduce the artist platform example (break-even 41, goal 100, ~548 THB kept per copy at 100). artist-platform.md: demo paragraph, side length per format table with sources, VAT note on the campaign example. README bullet and layout line, site DEMOS line and landing-page entry.
961 lines
83 KiB
HTML
961 lines
83 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>CNX Vinyl · make a record, or let your fans fund it (demo)</title>
|
||
<script>
|
||
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
|
||
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
|
||
document.documentElement.setAttribute("data-theme",t);})();
|
||
</script>
|
||
<style>
|
||
:root {
|
||
--bg: #0a0f1a; --panel: #0f1626; --panel2: #131c30; --stroke: #2a3a56; --line: #1c2840;
|
||
--fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84; --warn: #ffb547; --bad: #ff6b6b; --info: #6aa8ff;
|
||
--input: #0a1120; --onacc: #06210f;
|
||
--bv: #9fd0ff; --bvb: #2c4c73; --bd: #b5e8c7; --bdb: #2b5a3d; --be: #ffd59a; --beb: #6b5426;
|
||
}
|
||
:root { color-scheme: dark; }
|
||
html[data-theme=light] {
|
||
color-scheme: light;
|
||
--bg: #f4f6fa; --panel: #ffffff; --panel2: #eef2f8; --stroke: #cfd8e6; --line: #e4e9f1;
|
||
--fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --warn: #c27c00; --bad: #c62828; --info: #2f7de1;
|
||
--input: #ffffff; --onacc: #ffffff;
|
||
--bv: #1f5f9e; --bvb: #9cc0e6; --bd: #1e7a45; --bdb: #9fd3b3; --be: #8a5a00; --beb: #e3c27a;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||
a { color: var(--accent); }
|
||
header, .wrap { max-width: 1260px; margin: 0 auto; padding: 0 20px; }
|
||
header { padding-top: 28px; position: relative; }
|
||
h1 { margin: 0; font-size: 26px; letter-spacing: 0.02em; padding-right: 130px; }
|
||
.tag { display: inline-block; margin-top: 6px; padding: 2px 8px; border: 1px solid var(--stroke); border-radius: 4px; color: var(--mute); font-size: 13px; }
|
||
.lead { color: var(--mute); max-width: 920px; margin: 14px 0 18px; }
|
||
.themebtn { position: absolute; top: 24px; right: 20px; background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer; }
|
||
.themebtn:hover { border-color: var(--accent); }
|
||
nav.steps { position: sticky; top: 0; z-index: 20; background: var(--bg); border-bottom: 1px solid var(--stroke); margin-bottom: 18px; }
|
||
nav.steps .in { max-width: 1260px; margin: 0 auto; padding: 8px 20px; display: flex; gap: 6px; overflow-x: auto; }
|
||
nav.steps button { flex: 1 0 auto; display: flex; align-items: center; gap: 8px; background: transparent; border: 1px solid transparent; border-radius: 6px; color: var(--mute); padding: 7px 10px; font: inherit; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
||
nav.steps button:hover { color: var(--fg); }
|
||
nav.steps .n { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--stroke); font-size: 12px; }
|
||
nav.steps button.on { color: var(--fg); border-color: var(--stroke); background: var(--panel); }
|
||
nav.steps button.on .n { border-color: var(--accent); color: var(--accent); }
|
||
nav.steps button.done .n { background: var(--accent); border-color: var(--accent); color: var(--onacc); }
|
||
.grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; align-items: start; }
|
||
aside { position: sticky; top: 66px; }
|
||
@media (max-width: 980px) { .grid { grid-template-columns: minmax(0, 1fr); } aside { position: static; } }
|
||
.grid2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: start; }
|
||
@media (max-width: 1180px) { .grid2 { grid-template-columns: minmax(0, 1fr); } }
|
||
.card { background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px; padding: 16px 18px; margin-bottom: 18px; }
|
||
.card.okb { border-color: var(--accent); } .card.warnb { border-color: var(--warn); }
|
||
h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--mute); margin: 0 0 12px; }
|
||
h3 { margin: 0 0 6px; font-size: 16px; }
|
||
h4 { margin: 14px 0 6px; font-size: 13px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
|
||
p { margin: 8px 0; }
|
||
.row { display: flex; justify-content: space-between; align-items: center; gap: 8px 12px; margin: 9px 0; flex-wrap: wrap; }
|
||
.lbl { min-width: 130px; }
|
||
.seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--stroke); border-radius: 6px; overflow: hidden; }
|
||
.seg button { background: transparent; color: var(--mute); border: 0; padding: 6px 11px; font: inherit; font-size: 14px; cursor: pointer; }
|
||
.seg button + button { border-left: 1px solid var(--stroke); }
|
||
.seg button.on { background: var(--panel2); color: var(--accent); }
|
||
.seg button:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
input[type=number], input[type=text], select { background: var(--input); color: var(--fg); border: 1px solid var(--stroke); border-radius: 4px; padding: 5px 7px; font: inherit; }
|
||
input[type=number] { width: 96px; text-align: right; font-variant-numeric: tabular-nums; }
|
||
input[type=text]:focus, input[type=number]:focus, select:focus { outline: none; border-color: var(--accent); }
|
||
input.err { border-color: var(--bad); }
|
||
input[type=range] { accent-color: var(--accent); }
|
||
input[type=color] { width: 38px; height: 28px; padding: 0 2px; border: 1px solid var(--stroke); border-radius: 4px; background: var(--input); vertical-align: middle; }
|
||
.wide { width: 100%; }
|
||
.hint, small { color: var(--mute); font-size: 12.5px; }
|
||
.checks2 { display: flex; flex-wrap: wrap; gap: 6px 16px; }
|
||
.drop { display: flex; align-items: center; gap: 8px; border: 1.5px dashed var(--stroke); border-radius: 6px; padding: 8px 12px; margin: 8px 0; color: var(--mute); font-size: 14px; cursor: pointer; }
|
||
.drop:hover, .drop.over { border-color: var(--accent); color: var(--fg); }
|
||
.drop b { color: var(--fg); font-weight: 600; }
|
||
table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
||
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||
th { color: var(--mute); font-weight: 600; font-size: 12.5px; }
|
||
tr.hd th { padding-top: 12px; }
|
||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
tr.sub td { color: var(--mute); }
|
||
tr.tot td { font-weight: 700; border-top: 1px solid var(--stroke); font-size: 15px; }
|
||
.tscroll { overflow-x: auto; }
|
||
#trk td { vertical-align: middle; }
|
||
#trk input.tt { width: 100%; min-width: 130px; }
|
||
#trk input.tdur { width: 62px; text-align: right; }
|
||
.x { background: transparent; border: 0; color: var(--mute); font-size: 18px; cursor: pointer; padding: 0 4px; }
|
||
.x:hover { color: var(--bad); }
|
||
.pass { color: var(--info); font-size: 12px; white-space: nowrap; }
|
||
.badge { display: inline-block; padding: 0 6px; border-radius: 4px; font-size: 11.5px; border: 1px solid; white-space: nowrap; }
|
||
.b-doc { color: var(--bd); border-color: var(--bdb); } .b-team { color: var(--bv); border-color: var(--bvb); } .b-est { color: var(--be); border-color: var(--beb); }
|
||
.st { display: inline-block; min-width: 16px; text-align: center; font-weight: 700; }
|
||
.st.ok { color: var(--accent); } .st.warn { color: var(--warn); } .st.bad { color: var(--bad); } .st.pend { color: var(--mute); }
|
||
ul.chk { list-style: none; padding: 0; margin: 0; }
|
||
ul.chk li { display: flex; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||
ul.chk li:last-child { border-bottom: 0; }
|
||
.btn { background: var(--accent); color: var(--onacc); border: 1px solid var(--accent); border-radius: 6px; padding: 8px 14px; font: inherit; font-weight: 600; cursor: pointer; }
|
||
.btn.sec { background: transparent; color: var(--fg); border-color: var(--stroke); font-weight: 500; }
|
||
.btn.sec:hover { border-color: var(--accent); }
|
||
.btn.sm { padding: 4px 10px; font-size: 13px; }
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.bar { height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; position: relative; margin: 4px 0 2px; }
|
||
.bar.big { height: 12px; }
|
||
.bar > i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); border-radius: 5px; transition: width 0.15s; }
|
||
.bar > i.warn { background: var(--warn); } .bar > i.bad { background: var(--bad); }
|
||
.bar > b { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--fg); opacity: 0.5; }
|
||
.side { margin: 8px 0 12px; }
|
||
.side .row { margin: 0; }
|
||
.kv { display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; font-size: 14px; margin: 10px 0; }
|
||
.kv > span { color: var(--mute); }
|
||
.kv > b { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.okc { color: var(--accent); } .mutec { color: var(--mute); }
|
||
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||
@media (max-width: 640px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
.kpis div { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 6px; padding: 8px 10px; }
|
||
.kpis b { display: block; font-size: 18px; font-variant-numeric: tabular-nums; }
|
||
.note { border-left: 3px solid var(--warn); padding: 6px 10px; background: var(--panel2); border-radius: 4px; font-size: 13.5px; margin: 10px 0; }
|
||
.note.bad { border-left-color: var(--bad); }
|
||
.opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
|
||
@media (max-width: 640px) { .opts { grid-template-columns: minmax(0, 1fr); } }
|
||
.opt { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
|
||
.opt p { flex: 1; margin: 0 0 6px; font-size: 14px; color: var(--mute); }
|
||
ul.rules { margin: 0; padding-left: 18px; font-size: 14px; }
|
||
ul.rules li { margin: 5px 0; }
|
||
.mock svg { width: 100%; height: auto; display: block; }
|
||
.sum-t { font-weight: 700; font-size: 17px; margin-top: 8px; }
|
||
.navb { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||
.art { display: flex; gap: 12px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
|
||
.art img { width: 84px; height: 84px; object-fit: cover; border-radius: 4px; border: 1px solid var(--stroke); flex: none; }
|
||
.art ul { list-style: none; padding: 0; margin: 4px 0; font-size: 13.5px; }
|
||
.phone { max-width: 390px; margin: 0 auto; border: 1px solid var(--stroke); border-radius: 18px; padding: 14px; background: var(--panel2); }
|
||
.fp-t { font-size: 20px; font-weight: 700; margin-top: 10px; }
|
||
.fp-a { color: var(--mute); }
|
||
.fp-p { font-size: 18px; font-weight: 700; margin: 8px 0 2px; }
|
||
.fp-s { font-size: 14px; }
|
||
.fp-tr { padding-left: 0; list-style: none; margin: 0; font-size: 14px; }
|
||
.fp-tr li { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
|
||
.fp-tr li .tn { flex: 1; }
|
||
.fp-tr .sd { color: var(--mute); font-size: 12px; width: 14px; flex: none; }
|
||
.pv { background: transparent; border: 1px solid var(--stroke); color: var(--fg); border-radius: 50%; width: 26px; height: 26px; font-size: 10px; cursor: pointer; flex: none; }
|
||
.pv:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
.pv.on { border-color: var(--accent); color: var(--accent); }
|
||
.share { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0 6px; }
|
||
.fp-f { font-size: 12px; color: var(--mute); margin: 8px 0 0; }
|
||
.simrow input[type=range] { flex: 1; min-width: 140px; }
|
||
ol.tl { list-style: none; padding: 0; margin: 0; }
|
||
ol.tl li { display: flex; gap: 10px; align-items: flex-start; padding: 7px 0; border-bottom: 1px solid var(--line); }
|
||
ol.tl li > div { flex: 1; min-width: 0; }
|
||
ol.tl .dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--stroke); margin-top: 4px; flex: none; }
|
||
ol.tl li.done .dot { background: var(--accent); border-color: var(--accent); }
|
||
ol.tl li.now .dot { background: var(--warn); border-color: var(--warn); }
|
||
ol.tl li.next { color: var(--mute); }
|
||
ol.tl li.gate b::before { content: "\25C6 "; color: var(--info); }
|
||
ul.feed { list-style: none; padding: 0; margin: 0; max-height: 360px; overflow: auto; }
|
||
ul.feed li { padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
|
||
ul.feed small { display: block; }
|
||
details summary { cursor: pointer; color: var(--mute); font-size: 13.5px; margin-top: 8px; }
|
||
.cv-grid { stroke: var(--line); } .cv-axis { stroke: var(--stroke); }
|
||
.cv-line { stroke: var(--accent); fill: none; stroke-width: 2.2; }
|
||
.cv-t { fill: var(--mute); font-size: 11px; } .cv-m { fill: var(--fg); font-size: 12px; font-weight: 700; }
|
||
.cv-dot { fill: var(--accent); }
|
||
.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--accent); border-radius: 6px; padding: 8px 14px; font-size: 14px; z-index: 50; }
|
||
footer { color: var(--mute); font-size: 13px; padding-bottom: 40px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
|
||
<h1>Make a record</h1>
|
||
<span class="tag">CNX Vinyl · artist ordering demo · indicative prices · nothing is uploaded, sent or charged</span>
|
||
<p class="lead">Configure a record, check your music and artwork, and get an itemised
|
||
offer. Then order it directly, or launch a fan-funded campaign: fans pre-order,
|
||
and if enough of them do before the deadline the plant presses and ships each
|
||
fan's copy; if not, everyone is refunded. Every rate, and how it was set, is
|
||
listed at the bottom of the page.</p>
|
||
</header>
|
||
<nav class="steps" id="steps"><div class="in">
|
||
<button type="button" data-act="go" data-s="1"><span class="n">1</span>Record</button>
|
||
<button type="button" data-act="go" data-s="2"><span class="n">2</span>Music & artwork</button>
|
||
<button type="button" data-act="go" data-s="3"><span class="n">3</span>Offer</button>
|
||
<button type="button" data-act="go" data-s="4"><span class="n">4</span>Fan funding</button>
|
||
<button type="button" data-act="go" data-s="5"><span class="n">5</span>Campaign & production</button>
|
||
</div></nav>
|
||
|
||
<div class="wrap grid">
|
||
<main>
|
||
<!-- STEP 1 -->
|
||
<section class="step" data-step="1">
|
||
<div class="card">
|
||
<h2>Format</h2>
|
||
<div class="row"><span class="lbl">Size</span><div class="seg" data-k="size"><button type="button" data-v="12">12" LP</button><button type="button" data-v="10">10"</button><button type="button" data-v="7">7" single</button></div></div>
|
||
<div class="row"><span class="lbl">Speed</span><div class="seg" data-k="rpm"><button type="button" data-v="33">33⅓ rpm</button><button type="button" data-v="45">45 rpm</button></div></div>
|
||
<div class="row"><span class="lbl">Discs</span><div class="seg" data-k="discs" data-num><button type="button" data-v="1">Single</button><button type="button" data-v="2">Double</button></div></div>
|
||
<div class="row" id="rowWeight"><span class="lbl">Weight</span><div class="seg" data-k="weight" data-num><button type="button" data-v="140">140 g</button><button type="button" data-v="180">180 g</button></div></div>
|
||
<div class="row"><span class="lbl">Vinyl colour</span><div class="seg" data-k="colour"><button type="button" data-v="black">Black</button><button type="button" data-v="solid">Solid colour</button><button type="button" data-v="marbled">Marbled</button><button type="button" data-v="splatter">Splatter</button></div></div>
|
||
<div class="row" id="rowColours"><span class="lbl">Colours</span><span><input type="color" id="c1" title="Vinyl colour"> <input type="color" id="c2" title="Second colour"> <small id="colHint"></small></span></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Copies, for a direct order</h2>
|
||
<div class="row"><span class="lbl">Copies</span><input type="number" id="qty" min="100" max="5000" step="50"></div>
|
||
<input type="range" id="qtyR" class="wide" min="100" max="2000" step="50">
|
||
<p class="hint">From 100 copies. In a fan-funded campaign the run is set by the pre-orders instead (step 4).</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Sleeves and jacket</h2>
|
||
<div class="row"><span class="lbl">Inner sleeve</span><div class="seg" data-k="inner"><button type="button" data-v="paper">Paper</button><button type="button" data-v="poly">Poly-lined</button></div></div>
|
||
<div class="row"><span class="lbl">Jacket</span><div class="seg" data-k="jacket"><button type="button" data-v="none">I supply it</button><button type="button" data-v="plain">Plain</button><button type="button" data-v="printed">Printed</button><button type="button" data-v="gatefold" id="bGate">Gatefold</button></div></div>
|
||
<div class="row"><span class="lbl">Extras</span><span class="checks2">
|
||
<label><input type="checkbox" data-b="shrink"> Shrink wrap</label>
|
||
<label><input type="checkbox" data-b="insert"> Printed insert</label>
|
||
<label><input type="checkbox" data-b="sticker"> Hype sticker</label>
|
||
<label><input type="checkbox" data-b="numbered"> Numbered edition</label></span></div>
|
||
<p class="hint">Labels are printed and pressed into the record, the only kind this plant makes. Printed jackets come from a local packaging partner and are passed through at the partner's price plus a stated handling margin.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Audio and test pressings</h2>
|
||
<div class="row"><span class="lbl">Master</span><div class="seg" data-k="mastering" data-bool><button type="button" data-v="false">I supply a vinyl-ready master</button><button type="button" data-v="true">CNX vinyl mastering</button></div></div>
|
||
<div class="row"><span class="lbl">Test pressings for you</span><input type="number" id="tps" min="0" max="10" step="1"></div>
|
||
<p class="hint">Cutting and plating happen in-house: lacquer, father, mother and stampers. The mother is kept, so a repress later skips cutting.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- STEP 2 -->
|
||
<section class="step" data-step="2" hidden>
|
||
<div class="card">
|
||
<h2>Release</h2>
|
||
<div class="row"><span class="lbl">Artist</span><input type="text" id="artist" maxlength="60"></div>
|
||
<div class="row"><span class="lbl">Title</span><input type="text" id="title" maxlength="80"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Tracks and sides</h2>
|
||
<label class="drop" id="dropAudio"><input type="file" id="fileAudio" accept=".wav,.flac,.aif,.aiff,.aifc,audio/*" multiple hidden>⬇ <span><b>Drop WAV, FLAC or AIFF masters here</b> or click to choose. They are read in this browser; nothing is uploaded.</span></label>
|
||
<div class="tscroll"><table id="trk"></table></div>
|
||
<div class="row">
|
||
<span><button type="button" class="btn sec sm" data-act="add">+ Add a track</button> <button type="button" class="btn sec sm" data-act="split">Split sides evenly</button></span>
|
||
<span>Music <div class="seg" data-k="loud" data-bool><button type="button" data-v="false">Standard</button><button type="button" data-v="true">Loud, bass-heavy</button></div></span>
|
||
</div>
|
||
<div id="sides"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Artwork</h2>
|
||
<label class="drop" id="dropJacket"><input type="file" id="fileJacket" accept="image/*" hidden>⬇ <span><b>Jacket front</b>: square, 300 dpi, 3 mm bleed. Drop it here or click.</span></label>
|
||
<label class="drop" id="dropLabel"><input type="file" id="fileLabel" accept="image/*" hidden>⬇ <span><b>Label, side A</b>: square, 300 dpi. Drop it here or click.</span></label>
|
||
<div id="artRes"></div>
|
||
<p class="hint">The real platform checks every file against the CNX templates (size, centre hole, safe area, bleed) and renders a proof. Template sizes here are placeholders until those templates exist.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Automatic checks</h2>
|
||
<ul class="chk" id="checks"></ul>
|
||
<p><label><input type="checkbox" id="rights"> I own or have licensed these recordings and the compositions on them (cover versions need a mechanical licence).</label></p>
|
||
<button type="button" class="btn" data-act="gate1" id="gate1">Approve the master plan (gate 1)</button>
|
||
<p class="hint" id="gate1Msg"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- STEP 3 -->
|
||
<section class="step" data-step="3" hidden>
|
||
<div class="card">
|
||
<div class="row" style="margin-top:0"><h2 style="margin:0">Your offer</h2><div class="seg" data-k="cur"><button type="button" data-v="THB">THB</button><button type="button" data-v="USD">USD</button></div></div>
|
||
<p class="hint" id="offerHead"></p>
|
||
<div class="tscroll"><table id="offer"></table></div>
|
||
<p class="hint">Excludes 7 % VAT and freight. <span class="pass">↗ pass-through</span> lines are what the partner charges plus the handling margin shown.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Price per copy against quantity</h2>
|
||
<div id="curve"></div>
|
||
<p class="hint">The per-title costs are spread over the run, so the price per copy falls fast up to a few hundred copies. The small steps are the jacket printer's price breaks at 300, 500 and 1,000.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>When it ships</h2>
|
||
<div id="leadBox"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>How do you want to pay for it?</h2>
|
||
<div class="opts">
|
||
<div class="opt"><h3>Order directly</h3><p>Like any label: a deposit now, you approve the master and the test pressing, and the run ships to you or straight to your buyers.</p><button type="button" class="btn" data-act="direct" id="bDirect">Order directly (simulated)</button></div>
|
||
<div class="opt"><h3>Let your fans fund it</h3><p>Fans pre-order on a campaign page. Pressed if enough of them do before the deadline, every fan refunded if not. Nothing to pay up front.</p><button type="button" class="btn" data-act="go" data-s="4" id="bCamp">Set up a campaign →</button></div>
|
||
</div>
|
||
<p class="hint" id="choiceMsg"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- STEP 4 -->
|
||
<section class="step" data-step="4" hidden>
|
||
<div class="card">
|
||
<h2>Campaign</h2>
|
||
<div class="row"><span class="lbl">Fan price per copy</span><span><input type="number" id="fanPrice" min="300" max="5000" step="50"> THB</span></div>
|
||
<div class="row"><span class="lbl">Runs for</span><span style="display:flex;gap:8px;align-items:center"><input type="range" id="days" min="14" max="45" step="1"> <b id="daysV"></b> days</span></div>
|
||
<div class="row"><span class="lbl">Limited edition</span><span><input type="number" id="cap" min="0" max="5000" step="50"> <small>copies at most, 0 = no cap</small></span></div>
|
||
<div class="row"><span class="lbl">Copies for yourself</span><input type="number" id="acop" min="0" max="200" step="5"></div>
|
||
<p><label><input type="checkbox" id="vat"> Take 7 % VAT out of the fan price (CNX as the seller)</label></p>
|
||
<p class="hint">The record itself is set in step 1 (format, colour, packaging); the run size comes from the pre-orders. Shipping is charged to each fan at cost at checkout and is not in these numbers.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>The goal, and what you keep</h2>
|
||
<div id="campEcon"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Where the fans' money goes</h2>
|
||
<ul class="rules">
|
||
<li><b>Charged when they pre-order</b>, by PromptPay QR, card or wallet, which is how Thai fans pay.</li>
|
||
<li><b>Held in a separate account</b> and never used to run the plant until the campaign succeeds. This is the lesson of Qrates, whose service is suspended (notice on its site, October 2026) with funded projects caught in the wind-down.</li>
|
||
<li><b>Refunded automatically</b> to every fan if the goal is missed.</li>
|
||
<li><b>CNX is the seller</b> to the fan: it makes and sells the record, you license the music and receive the margin, so VAT, consumer protection and refunds sit in one place.</li>
|
||
<li>Card data never touches CNX servers: the payment provider's hosted page takes it. A Thai legal opinion on holding pre-order money comes before the first real campaign.</li>
|
||
</ul>
|
||
</div>
|
||
<div class="card">
|
||
<button type="button" class="btn" data-act="launch" id="bLaunch">Launch the campaign (simulated) →</button>
|
||
<p class="hint" id="launchMsg"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- STEP 5 -->
|
||
<section class="step" data-step="5" hidden>
|
||
<div id="s5"></div>
|
||
</section>
|
||
</main>
|
||
<aside><div class="card" id="sumCard"></div></aside>
|
||
</div>
|
||
|
||
<div class="wrap">
|
||
<div class="card" id="plCard"></div>
|
||
<footer>Demo page from <code>docs/artist-order.html</code> in the cnx_vinyl planning repository; the design it follows is the artist platform plan. Prices are indicative and are not an offer.</footer>
|
||
</div>
|
||
|
||
<script>
|
||
(function(){
|
||
"use strict";
|
||
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
|
||
const esc=s=>String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
const THB_USD=33, USD_EUR=1.155, PLV="demo-0.1";
|
||
|
||
/* ---- price list: every rate with its basis (doc = planning docs, team = team figure, est = estimate) ---- */
|
||
const R={
|
||
master:{v:5000,u:"per side",t:"Vinyl mastering",k:"est",src:"Estimate. For scale: audio mastering in the Thai market runs 5,000 to 20,000 THB per song (revenue model)."},
|
||
blank:{v:Math.round(70*USD_EUR*THB_USD),u:"per side",t:"Lacquer blank, one per side",k:"team",src:"70 EUR a blank (team figure, October 2026, revenue model) at 1 EUR = 1.155 USD and 1 USD = 33 THB."},
|
||
cut:{v:2000,u:"per side",t:"Lacquer cutting: engineer and lathe time",k:"est",src:"Estimate. A cutting engineer earns about 50,000 THB a month (team figure, revenue model)."},
|
||
metal:{v:7000,u:"per side",t:"Electroforming: father, mother (kept for represses) and stamper",k:"est",src:"Estimate. A Thai buyer pays up to 22,000 THB per title for a bought-in stamper set today (revenue model, an upper bound)."},
|
||
setup:{v:3000,u:"per disc",t:"Press setup: mould change, stamper mount, warm-up",k:"est",src:"Estimate. With the lines above the default record (two sides, mastering, 5 test pressings) comes to about 37,800 THB per title, close to the 38,000 THB midpoint the artist platform illustration uses."},
|
||
tp:{v:300,u:"per copy",t:"Test pressing for the artist",k:"est",src:"Estimate."},
|
||
press:{v:{12:115,10:95,7:55},u:"per disc",t:"Pressing with printed labels",k:"doc",src:"12\", 140 g: with a paper inner sleeve (7) and assembly (10) this is 132 THB, the midpoint of the 115 to 150 THB working assumption (revenue model, artist platform). 10\" and 7\" are estimates."},
|
||
g180:{v:15,u:"per 12\" disc",t:"180 g instead of 140 g",k:"est",src:"Estimate; Western plants charge a premium for 180 g (revenue model)."},
|
||
colour:{v:{solid:20,marbled:35,splatter:45},u:"per disc",t:"Colour vinyl",k:"doc",src:"Inside the +0.60 to 1.70 USD (20 to 56 THB) per record that Western plants charge for colour and splatter (revenue model); the split is an estimate."},
|
||
inner:{v:{paper:{12:7,10:6,7:4},poly:{12:15,10:12,7:8}},u:"per disc",t:"Inner sleeve",k:"est",src:"Paper is inside the 132 THB above; a poly-lined sleeve costs 3.0 to 5.7 THB (revenue model); prices are estimates."},
|
||
assy:{v:10,u:"per copy",t:"Assembly and packing",k:"est",src:"Part of the 132 THB above; the split is an estimate."},
|
||
jacket:{v:{printed:{12:[86,73,64,55],10:[68,58,51,44],7:[27,23,20,17]},gatefold:{12:[127,109,95,82]},plain:{12:[27,27,27,27],10:[20,20,20,20],7:[8,8,8,8]}},u:"per copy, partner price",t:"Jacket from the packaging partner, price breaks at 300 / 500 / 1,000",k:"est",src:"Estimate until the packaging partner quotes. With handling and shrink a printed 12\" jacket comes to about 100 THB a copy at short run, as in the artist platform illustration."},
|
||
handling:{v:0.10,u:"on pass-through",t:"Handling margin on the jacket",k:"est",src:"Placeholder. The artist platform shows pass-through items at the partner price plus a stated margin."},
|
||
shrink:{v:5,u:"per copy",t:"Shrink wrap",k:"est",src:"Film costs 0.8 to 1.1 THB (revenue model); price an estimate."},
|
||
insert:{v:12,u:"per copy",t:"Printed insert",k:"est",src:"Estimate."},
|
||
sticker:{v:6,u:"per copy",t:"Hype sticker",k:"est",src:"Estimate."},
|
||
numbered:{v:5,u:"per copy",t:"Hand-numbered edition",k:"est",src:"Estimate."},
|
||
fee:{v:0.035,u:"of the fan price",t:"Payment fees",k:"doc",src:"About 3 to 4 % on cards, less on PromptPay, to be confirmed with the provider (artist platform)."},
|
||
minRun:{v:100,u:"copies",t:"Minimum campaign run",k:"doc",src:"Planning figure, to be decided (artist platform, open decision 4)."},
|
||
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."}
|
||
};
|
||
const tierIdx=q=>q>=1000?3:q>=500?2:q>=300?1:0;
|
||
/* side length per format, minutes [comfortable, at most]: microcut.studio format guide (lower end of each range), checked October 2026 */
|
||
const LIM={"12":{"33":[18,22],"45":[12,15]},"10":{"33":[12,16],"45":[8,11]},"7":{"33":[5,7],"45":[4.5,6]}};
|
||
const LOUD=0.6; /* Diggers Factory: dance music ~11:27 against 19:23 standard on a 12" LP */
|
||
const JNAME={none:"Jacket supplied by you",plain:"Plain jacket",printed:"Printed jacket",gatefold:"Printed gatefold jacket"};
|
||
const ART={jacket:{t:"Jacket front",mm:{12:320,10:266,7:190}},label:{t:"Label",mm:{12:106,10:106,7:96}}};
|
||
/* stages, working days [low, high]: mature column of the production timeline */
|
||
const STG=[
|
||
{id:"intake",t:"Audio intake and technical QA",d:[1,2]},
|
||
{id:"g1",t:"Gate 1: you approve the master plan",d:[2,10],gate:1},
|
||
{id:"mast",t:"Vinyl mastering",d:[2,4],opt:"mastering"},
|
||
{id:"g2",t:"Gate 2: you approve the vinyl master",d:[2,10],gate:2,opt:"mastering"},
|
||
{id:"cut",t:"Lathe cutting to lacquer",d:[1,2]},
|
||
{id:"plate",t:"Silvering and electroforming",d:[2,4]},
|
||
{id:"prep",t:"Stamper prep",d:[1,1]},
|
||
{id:"tp",t:"Press setup and test pressings",d:[0.5,1]},
|
||
{id:"g3",t:"Gate 3: you approve the test pressing",d:[5,15],gate:3},
|
||
{id:"queue",t:"Press slot (queue and buffer held in the quote)",d:null},
|
||
{id:"run",t:"Production run",d:[1,3]},
|
||
{id:"fin",t:"Finishing, assembly, packaging",d:[2,3]},
|
||
{id:"ship",t:"Fulfilment and dispatch",d:[1,2]}
|
||
];
|
||
const UPD={
|
||
mast:"Mastered for vinyl; the artist is listening to the master.",
|
||
g2:"The artist approved the vinyl master.",
|
||
cut:"Cut to lacquer in the cutting room.",
|
||
plate:"Plated in-house: father, mother and stampers. The mother is archived for represses.",
|
||
prep:"Stampers punched, back-finished and checked.",
|
||
tp:"First test pressings made and sent to the artist.",
|
||
g3:"The artist approved the test pressing.",
|
||
queue:"Pressing slot booked.",
|
||
run:P=>"Pressed: "+P.run+" copies, every one optically inspected and logged.",
|
||
fin:()=>"Packed: sleeves, jackets"+(S.shrink?" and shrink wrap":"")+".",
|
||
ship:P=>P.origin==="direct"?"Dispatched; tracking sent.":"Shipped: one parcel per fan, tracking sent to all "+P.fans+" fans."
|
||
};
|
||
|
||
const TODAY=new Date(); TODAY.setHours(12,0,0,0);
|
||
const S={
|
||
step:1,cur:"THB",size:"12",rpm:"33",discs:1,weight:140,colour:"black",c1:"#c0392b",c2:"#f2c14e",
|
||
qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false,
|
||
mastering:true,tps:5,loud:false,artist:"Demo Artist",title:"Night Market Songs",
|
||
tracks:[],demoTracks:true,rights:false,gate1:false,art:{jacket:null,label:null},
|
||
fanPrice:1200,days:30,cap:0,artistCopies:0,vat:false,
|
||
mode:null,launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0,prod:null,
|
||
visited:{1:true}
|
||
};
|
||
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]]
|
||
.forEach(([t,s],i)=>S.tracks.push({id:++tid,title:t,sec:s,side:i<4?"A":"B",file:null,info:null,url:null}));
|
||
|
||
/* ---- formatting ---- */
|
||
const fmtInt=n=>Math.round(n).toLocaleString("en-US");
|
||
function money(thb,o){o=o||{};
|
||
if(S.cur==="USD"){const u=thb/THB_USD,d=o.dec!=null?o.dec:(Math.abs(u)<1000?2:0);return u.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d})+(o.bare?"":" USD");}
|
||
return Math.round(thb).toLocaleString("en-US")+(o.bare?"":" THB");}
|
||
const pctT=v=>(Math.round(v*1000)/10)+" %";
|
||
function mmss(s){s=Math.round(s||0);return Math.floor(s/60)+":"+String(s%60).padStart(2,"0");}
|
||
const fmtD=d=>d.toLocaleDateString("en-GB",{day:"numeric",month:"short",year:"numeric"});
|
||
const fmtM=d=>d.toLocaleDateString("en-GB",{month:"short",year:"numeric"});
|
||
function monthRange(a,b){const A=fmtM(a),B=fmtM(b);if(A===B)return A;return a.getFullYear()===b.getFullYear()?a.toLocaleDateString("en-GB",{month:"short"})+" to "+B:A+" to "+B;}
|
||
const addDays=(d,n)=>{const x=new Date(d);x.setDate(x.getDate()+Math.round(n));return x;};
|
||
function addWD(d,wd){const x=new Date(d);let n=Math.ceil(wd);while(n>0){x.setDate(x.getDate()+1);const g=x.getDay();if(g!==0&&g!==6)n--;}return x;}
|
||
const sideNames=()=>S.discs>1?["A","B","C","D"]:["A","B"];
|
||
const rpmT=()=>S.rpm==="33"?"33⅓ rpm":"45 rpm";
|
||
const colourT=()=>({black:"black",solid:"solid colour",marbled:"marbled",splatter:"splatter"})[S.colour];
|
||
function descT(){return (S.discs>1?"2 × ":"")+S.size+"\", "+rpmT()+", "+(S.size==="12"?S.weight+" g ":"")+colourT()+", "+JNAME[S.jacket].toLowerCase();}
|
||
function toast(t){const el=document.createElement("div");el.className="toast";el.textContent=t;document.body.appendChild(el);setTimeout(()=>el.remove(),2200);}
|
||
|
||
/* ---- pricing ---- */
|
||
function quote(q){
|
||
const sz=S.size,d=S.discs,sides=2*d,T=[],C=[],du=d>1?"discs":"disc";
|
||
if(S.mastering)T.push({t:"Vinyl mastering",n:sides,u:"sides",r:R.master.v});
|
||
T.push({t:"Lacquer blanks, 70 EUR each",n:sides,u:"sides",r:R.blank.v});
|
||
T.push({t:"Lacquer cutting",n:sides,u:"sides",r:R.cut.v});
|
||
T.push({t:"Electroforming: father, mother (kept for represses), stamper",n:sides,u:"sides",r:R.metal.v});
|
||
T.push({t:"Press setup",n:d,u:du,r:R.setup.v});
|
||
if(S.tps>0)T.push({t:"Test pressings for you",n:S.tps,u:"copies",r:R.tp.v});
|
||
C.push({t:"Pressing, "+sz+"\""+(sz==="12"?" 140 g":"")+", printed labels",n:d,u:du,r:R.press.v[sz]});
|
||
if(sz==="12"&&S.weight===180)C.push({t:"180 g instead of 140 g",n:d,u:du,r:R.g180.v});
|
||
if(S.colour!=="black")C.push({t:"Colour vinyl, "+colourT(),n:d,u:du,r:R.colour.v[S.colour]});
|
||
C.push({t:"Inner sleeve, "+(S.inner==="poly"?"poly-lined":"paper"),n:d,u:du,r:R.inner.v[S.inner][sz]});
|
||
C.push({t:"Assembly and packing",n:1,u:"",r:R.assy.v});
|
||
if(S.jacket!=="none"){const p=R.jacket.v[S.jacket][sz][tierIdx(q)];C.push({t:JNAME[S.jacket]+(sz==="7"&&S.jacket!=="plain"?" (picture sleeve)":""),n:1,u:"",r:Math.round(p*(1+R.handling.v)),pass:p});}
|
||
if(S.shrink)C.push({t:"Shrink wrap",n:1,u:"",r:R.shrink.v});
|
||
if(S.insert)C.push({t:"Printed insert",n:1,u:"",r:R.insert.v});
|
||
if(S.sticker)C.push({t:"Hype sticker",n:1,u:"",r:R.sticker.v});
|
||
if(S.numbered)C.push({t:"Hand-numbered",n:1,u:"",r:R.numbered.v});
|
||
T.forEach(x=>x.a=x.n*x.r);C.forEach(x=>x.a=x.n*x.r);
|
||
const tSum=T.reduce((s,x)=>s+x.a,0),cSum=C.reduce((s,x)=>s+x.a,0);
|
||
return {T,C,tSum,cSum,q,total:tSum+q*cSum,per:tSum/q+cSum};
|
||
}
|
||
function leadInfo(campaign){
|
||
const g3=STG.findIndex(s=>s.id==="g3");
|
||
const pre=STG.slice(0,g3+1).filter(s=>(!s.opt||S[s.opt])&&!((campaign||S.gate1)&&(s.id==="intake"||s.id==="g1")));
|
||
const lo=pre.reduce((a,s)=>a+s.d[0],0),hi=pre.reduce((a,s)=>a+s.d[1],0);
|
||
const pw=[Math.max(1,Math.round(lo/5)),Math.max(1,Math.ceil(hi/5))];
|
||
const printed=S.jacket==="printed"||S.jacket==="gatefold";
|
||
const qw=printed?[7,9]:[5,8];
|
||
return {pre,pw,qw,tw:[pw[0]+qw[0],pw[1]+qw[1]],printed};
|
||
}
|
||
function campEcon(){
|
||
const p=S.fanPrice,fee=R.fee.v,vat=S.vat?p*7/107: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;}}
|
||
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};
|
||
}
|
||
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);
|
||
if(S.cap>0)P=Math.min(P,S.cap);return P;
|
||
}
|
||
/* share of the final pledges in by time t (0..1): launch spike, steady middle, last-days push */
|
||
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);
|
||
|
||
/* ---- 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;}
|
||
function sideStats(){
|
||
const L=LIM[S.size][S.rpm].map(m=>m*60*(S.loud?LOUD:1));
|
||
return sideNames().map(n=>{const tr=S.tracks.filter(t=>t.side===n),sec=tr.reduce((a,t)=>a+(t.sec||0),0);
|
||
return {n,sec,count:tr.length,st:!tr.length?"warn":sec<=L[0]?"ok":sec<=L[1]?"warn":"bad",lim:L};});
|
||
}
|
||
function autoSplit(){
|
||
const names=sideNames(),k=names.length,a=S.tracks.map(t=>t.sec||0),n=a.length;if(!n)return;
|
||
const pre=[0];a.forEach(x=>pre.push(pre[pre.length-1]+x));
|
||
const dp=[],cut=[];for(let j=0;j<=k;j++){dp.push(Array(n+1).fill(Infinity));cut.push(Array(n+1).fill(0));}
|
||
dp[0][0]=0;
|
||
for(let j=1;j<=k;j++)for(let i=0;i<=n;i++)for(let p=0;p<=i;p++){const v=Math.max(dp[j-1][p],pre[i]-pre[p]);if(v<dp[j][i]){dp[j][i]=v;cut[j][i]=p;}}
|
||
let i=n;for(let j=k;j>=1;j--){const p=cut[j][i];for(let x=p;x<i;x++)S.tracks[x].side=names[j-1];i=p;}
|
||
}
|
||
const trkOf=el=>S.tracks.find(t=>t.id===+el.closest("tr").dataset.id);
|
||
|
||
/* ---- reading files in the browser ---- */
|
||
const str=(dv,o,n)=>{let s="";for(let i=0;i<n&&o+i<dv.byteLength;i++)s+=String.fromCharCode(dv.getUint8(o+i));return s;};
|
||
async function dvAt(file,o,n){return new DataView(await file.slice(o,o+n).arrayBuffer());}
|
||
async function audioInfo(file){
|
||
let off=0,h=await dvAt(file,0,12);if(h.byteLength<12)return {fmt:"?",lossless:false};
|
||
if(str(h,0,3)==="ID3"){const b=await dvAt(file,6,4);off=10+((b.getUint8(0)<<21)|(b.getUint8(1)<<14)|(b.getUint8(2)<<7)|b.getUint8(3));h=await dvAt(file,off,12);}
|
||
const tag=str(h,0,4),sub=str(h,8,4);
|
||
if(tag==="RIFF"&&sub==="WAVE")return wavInfo(file);
|
||
if(tag==="fLaC")return flacInfo(file,off);
|
||
if(tag==="FORM"&&(sub==="AIFF"||sub==="AIFC"))return aiffInfo(file,sub==="AIFC");
|
||
return {fmt:(file.name.split(".").pop()||"?").toUpperCase(),lossless:false};
|
||
}
|
||
async function wavInfo(file){
|
||
let o=12,f=null,data=null;
|
||
while(o+8<=file.size&&(!f||data==null)){
|
||
const h=await dvAt(file,o,8),id=str(h,0,4),sz=h.getUint32(4,true);
|
||
if(id==="fmt "){const d=await dvAt(file,o+8,Math.min(sz,40));let tg=d.getUint16(0,true);if(tg===0xFFFE&&sz>=26)tg=d.getUint16(24,true);f={tg,ch:d.getUint16(2,true),sr:d.getUint32(4,true),bits:d.getUint16(14,true)};}
|
||
else if(id==="data")data=Math.min(sz,file.size-o-8);
|
||
o+=8+sz+(sz&1);
|
||
}
|
||
if(!f||data==null)return {fmt:"WAV",lossless:false,err:"unreadable header"};
|
||
const bps=f.sr*f.ch*f.bits/8;
|
||
return {fmt:"WAV",lossless:f.tg===1||f.tg===3,ch:f.ch,sr:f.sr,bits:f.bits,sec:bps?data/bps:0};
|
||
}
|
||
async function flacInfo(file,off){
|
||
const d=await dvAt(file,off+8,18),b=i=>d.getUint8(i);
|
||
const sr=(b(10)<<12)|(b(11)<<4)|(b(12)>>4),ch=((b(12)>>1)&7)+1,bits=(((b(12)&1)<<4)|(b(13)>>4))+1;
|
||
const total=(b(13)&15)*4294967296+((b(14)<<24)>>>0)+(b(15)<<16)+(b(16)<<8)+b(17);
|
||
return {fmt:"FLAC",lossless:true,ch,sr,bits,sec:sr?total/sr:0};
|
||
}
|
||
async function aiffInfo(file,isC){
|
||
let o=12;
|
||
while(o+8<=file.size){
|
||
const h=await dvAt(file,o,8),id=str(h,0,4),sz=h.getUint32(4,false);
|
||
if(id==="COMM"){
|
||
const d=await dvAt(file,o+8,Math.min(sz,22)),ch=d.getUint16(0),frames=d.getUint32(2),bits=d.getUint16(6);
|
||
const e=d.getUint16(8)&0x7FFF,hi=d.getUint32(10),lo=d.getUint32(14),sr=(hi*4294967296+lo)*Math.pow(2,e-16383-63);
|
||
const comp=isC&&sz>=22?str(d,18,4):"NONE";
|
||
return {fmt:isC?"AIFF-C":"AIFF",lossless:["NONE","sowt","twos","fl32","FL32","in24","in32"].includes(comp),ch,sr:Math.round(sr),bits,sec:sr?frames/sr:0};
|
||
}
|
||
o+=8+sz+(sz&1);
|
||
}
|
||
return {fmt:"AIFF",lossless:false,err:"no COMM chunk"};
|
||
}
|
||
async function scanPeak(t){
|
||
if(!t.info.lossless||t.file.size>150e6){t.info.peak="skip";return;}
|
||
try{
|
||
const ab=await t.file.arrayBuffer(),ctx=new OfflineAudioContext(1,1,Math.min(192000,Math.max(8000,t.info.sr||44100)));
|
||
const buf=await ctx.decodeAudioData(ab);let pk=0,runs=0;
|
||
for(let c=0;c<buf.numberOfChannels;c++){const d=buf.getChannelData(c);let run=0;for(let i=0;i<d.length;i++){const a=Math.abs(d[i]);if(a>pk)pk=a;if(a>=0.9999){run++;if(run===3)runs++;}else run=0;}}
|
||
t.info.peak=pk;t.info.clips=runs;
|
||
}catch(e){t.info.peak="skip";}
|
||
}
|
||
async function jpegComponents(file){
|
||
const b=new Uint8Array(await file.slice(0,262144).arrayBuffer());let i=2;
|
||
while(i+9<b.length){if(b[i]!==0xFF){i++;continue;}const m=b[i+1];
|
||
if(m===0xD8||m===0x01||m===0xFF||(m>=0xD0&&m<=0xD7)){i+=(m===0xFF?1:2);continue;}
|
||
const len=(b[i+2]<<8)|b[i+3];if(m>=0xC0&&m<=0xCF&&m!==0xC4&&m!==0xC8&&m!==0xCC)return b[i+9];i+=2+len;}
|
||
return null;
|
||
}
|
||
async function readImage(file){
|
||
const url=URL.createObjectURL(file),img=new Image();img.src=url;await img.decode();
|
||
const comps=(file.type==="image/jpeg"||/\.jpe?g$/i.test(file.name))?await jpegComponents(file):null;
|
||
return {url,w:img.naturalWidth,h:img.naturalHeight,cmyk:comps===4,name:file.name};
|
||
}
|
||
function artChecks(k){
|
||
const a=S.art[k];if(!a)return null;if(a.err)return [["pend",a.err]];
|
||
const mm=ART[k].mm[S.size],need=Math.round(mm/25.4*300),px=Math.min(a.w,a.h),dpi=Math.round(px/(mm/25.4)),out=[];
|
||
out.push(Math.abs(a.w/a.h-1)<0.01?["ok","Square"]:["bad","Not square ("+a.w+" × "+a.h+" px)"]);
|
||
out.push(px>=need?["ok",a.w+" × "+a.h+" px: "+dpi+" dpi at "+mm+" mm"+(k==="jacket"?" incl. 3 mm bleed":"")]:px>=need*0.8?["warn",dpi+" dpi at "+mm+" mm, under 300 (needs "+need+" px)"]:["bad",dpi+" dpi at "+mm+" mm: too low for print (needs "+need+" px)"]);
|
||
out.push(a.cmyk?["ok","CMYK"]:["warn","RGB: the printer converts it to CMYK, and bright colours can shift"]);
|
||
return out;
|
||
}
|
||
|
||
/* ---- checks before gate 1 ---- */
|
||
function checkList(){
|
||
const L=[],files=S.tracks.filter(t=>t.file),ready=files.filter(t=>t.info&&!t.info.reading);
|
||
if(!S.tracks.length)L.push(["bad","Track list","Add at least one track."]);
|
||
if(!files.length)L.push(["pend","Master files","None added: the checks use the lengths typed in. Drop WAV, FLAC or AIFF files to check the files themselves."]);
|
||
else{
|
||
const lossy=ready.filter(t=>!t.info.lossless);
|
||
L.push(lossy.length?["bad","File format",lossy.map(t=>esc(t.file.name)).join(", ")+": not WAV, FLAC or AIFF. Send the lossless master."]:["ok","File format",ready.length+" lossless file"+(ready.length===1?"":"s")]);
|
||
const ok=ready.filter(t=>t.info.lossless),lowSr=ok.filter(t=>t.info.sr<44100),lowB=ok.filter(t=>t.info.bits<16),b16=ok.filter(t=>t.info.bits===16);
|
||
L.push(lowSr.length?["bad","Sample rate",lowSr.length+" file(s) below 44.1 kHz"]:["ok","Sample rate","44.1 kHz or more"]);
|
||
L.push(lowB.length?["bad","Bit depth",lowB.length+" file(s) below 16 bit"]:b16.length?["warn","Bit depth",b16.length+" file(s) at 16 bit: fine, 24 bit is preferred for cutting"]:["ok","Bit depth","24 bit or more"]);
|
||
const scanning=ok.filter(t=>t.info.scanning),clipped=ok.filter(t=>t.info.clips>0),hot=ok.filter(t=>typeof t.info.peak==="number"&&t.info.peak>=0.966&&!(t.info.clips>0));
|
||
L.push(scanning.length?["pend","Clipping and peak","Scanning "+scanning.length+" file(s)…"]:clipped.length?["warn","Clipping",clipped.map(t=>esc(t.title)).join(", ")+": runs of full-scale samples. The engineer will ask about it."]:hot.length?["warn","Peak level",hot.length+" file(s) peak above -0.3 dBFS (sample peak)"]:["ok","Clipping and peak","No clipping found (sample peak)"]);
|
||
const miss=S.tracks.filter(t=>!t.file).length;if(miss)L.push(["warn","Files",miss+" track(s) without a file yet"]);
|
||
}
|
||
const ss=sideStats(),bad=ss.filter(s=>s.st==="bad"),warn=ss.filter(s=>s.st==="warn"&&s.count),empty=ss.filter(s=>!s.count),lim=ss[0].lim;
|
||
L.push(bad.length?["bad","Side length",bad.map(s=>"side "+s.n+" "+mmss(s.sec)).join(", ")+": over the "+mmss(lim[1])+" a "+S.size+"\" side can hold at "+rpmT()+". Move tracks, add a disc or change the speed."]
|
||
:warn.length?["warn","Side length",warn.map(s=>"side "+s.n+" "+mmss(s.sec)).join(", ")+": past the comfortable "+mmss(lim[0])+", so the cut gets quieter"]
|
||
:empty.length?["warn","Side length","Side "+empty.map(s=>s.n).join(", ")+" has no music"]:["ok","Side length","Every side within the comfortable "+mmss(lim[0])]);
|
||
if(S.jacket==="printed"||S.jacket==="gatefold"){const c=artChecks("jacket");L.push(!c?["pend","Jacket artwork","Not added yet"]:c.some(x=>x[0]==="bad")?["bad","Jacket artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:c.some(x=>x[0]!=="ok")?["warn","Jacket artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:["ok","Jacket artwork","Size, resolution and colour space fine"]);}
|
||
{const c=artChecks("label");L.push(!c?["pend","Label artwork","Not added yet"]:c.some(x=>x[0]==="bad")?["bad","Label artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:c.some(x=>x[0]!=="ok")?["warn","Label artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:["ok","Label artwork","Size, resolution and colour space fine"]);}
|
||
L.push(["pend","Engineer check","After upload a cutting engineer confirms that side length and level are cuttable. This decides, not the table above."]);
|
||
L.push(["pend","Rights check","An audio fingerprint check catches obvious bootlegs before a person looks; you confirm the rights below."]);
|
||
return L;
|
||
}
|
||
|
||
/* ---- mock-up ---- */
|
||
function rnd(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;return ((t^t>>>14)>>>0)/4294967296;};}
|
||
function mockSVG(id){
|
||
const dark=S.colour==="black",base=dark?"#161616":S.c1,groove=dark?"rgba(255,255,255,0.07)":"rgba(0,0,0,0.10)";
|
||
let defs=`<clipPath id="${id}c"><circle r="105"/></clipPath><clipPath id="${id}l"><circle r="34"/></clipPath>`+
|
||
`<radialGradient id="${id}sh" cx="0.35" cy="0.3" r="0.9"><stop offset="0" stop-color="#fff" stop-opacity="0.13"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></radialGradient>`+
|
||
`<filter id="${id}m" x="-10%" y="-10%" width="120%" height="120%"><feTurbulence type="fractalNoise" baseFrequency="0.008 0.03" numOctaves="3" seed="4" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 7 0 0 0 -3.3" result="m"/><feFlood flood-color="${S.c2}" result="f"/><feComposite in="f" in2="m" operator="in"/></filter>`+
|
||
`<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${dark?"#3a4d70":S.c1}"/><stop offset="1" stop-color="#0d1322"/></linearGradient>`;
|
||
function disc(cx){
|
||
let g=`<g transform="translate(${cx} 122)"><circle r="105" fill="${base}"/>`;
|
||
if(S.colour==="marbled")g+=`<rect x="-105" y="-105" width="210" height="210" fill="${S.c2}" filter="url(#${id}m)" clip-path="url(#${id}c)"/>`;
|
||
if(S.colour==="splatter"){const r=rnd(7);let dots="";for(let i=0;i<80;i++){const a=r()*6.283,d=Math.sqrt(r())*100,s=1+r()*5;dots+=`<circle cx="${(Math.cos(a)*d).toFixed(1)}" cy="${(Math.sin(a)*d).toFixed(1)}" r="${s.toFixed(1)}" fill="${r()<0.8?S.c2:"#ffffff"}"/>`;}g+=`<g clip-path="url(#${id}c)">${dots}</g>`;}
|
||
for(let r=100;r>=40;r-=4)g+=`<circle r="${r}" fill="none" stroke="${groove}" stroke-width="0.6"/>`;
|
||
g+=`<circle r="105" fill="url(#${id}sh)"/>`;
|
||
if(S.art.label&&S.art.label.url)g+=`<image href="${S.art.label.url}" x="-34" y="-34" width="68" height="68" preserveAspectRatio="xMidYMid slice" clip-path="url(#${id}l)"/>`;
|
||
else g+=`<circle r="34" fill="#efe6d2"/><text y="-6" text-anchor="middle" font-size="7" font-weight="700" fill="#2a2a2a">${esc(S.artist.slice(0,20))}</text><text y="14" text-anchor="middle" font-size="6" fill="#444">${esc(S.title.slice(0,24))}</text>`;
|
||
return g+`<circle r="2.6" style="fill:var(--panel)"/></g>`;
|
||
}
|
||
let recs=(S.discs>1?disc(292):"")+disc(260),jk="";
|
||
const J={x:8,y:16,w:212};
|
||
if(S.jacket==="gatefold")jk+=`<rect x="${J.x-4}" y="${J.y-4}" width="${J.w}" height="${J.w}" fill="#0b101c" stroke="#30405e"/>`;
|
||
if(S.jacket==="none")jk+=`<rect x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" fill="none" style="stroke:var(--mute)" stroke-dasharray="6 5"/><text x="${J.x+J.w/2}" y="${J.y+J.w/2}" text-anchor="middle" font-size="13" style="fill:var(--mute)">your own jacket</text>`;
|
||
else if(S.art.jacket&&S.art.jacket.url&&S.jacket!=="plain")jk+=`<image href="${S.art.jacket.url}" x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" preserveAspectRatio="xMidYMid slice"/>`;
|
||
else if(S.jacket==="plain")jk+=`<rect x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" fill="#d8c3a0"/><circle cx="${J.x+J.w/2}" cy="${J.y+J.w/2}" r="40" fill="none" stroke="#b9a27c" stroke-width="2"/>`;
|
||
else jk+=`<rect x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" fill="url(#${id}g)"/><circle cx="${J.x+150}" cy="${J.y+70}" r="46" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="10"/><text x="${J.x+16}" y="${J.y+J.w-38}" font-size="16" font-weight="700" fill="#fff">${esc(S.title.slice(0,22))}</text><text x="${J.x+16}" y="${J.y+J.w-18}" font-size="12" fill="rgba(255,255,255,0.8)">${esc(S.artist.slice(0,28))}</text>`;
|
||
if(S.jacket!=="none"){
|
||
if(S.shrink)jk+=`<polygon points="${J.x},${J.y} ${J.x+90},${J.y} ${J.x},${J.y+90}" fill="#fff" opacity="0.07"/>`;
|
||
if(S.sticker)jk+=`<circle cx="${J.x+J.w-30}" cy="${J.y+28}" r="19" fill="#ffd23f"/><text x="${J.x+J.w-30}" y="${J.y+32}" text-anchor="middle" font-size="10" font-weight="700" fill="#222">NEW</text>`;
|
||
if(S.numbered)jk+=`<text x="${J.x+J.w-8}" y="${J.y+J.w-8}" text-anchor="end" font-size="9" fill="#fff" opacity="0.85">No. 001</text>`;
|
||
}
|
||
return `<svg viewBox="0 0 402 246" role="img" aria-label="Mock-up of the record"><defs>${defs}</defs>${recs}${jk}</svg>`;
|
||
}
|
||
|
||
/* ---- price curve ---- */
|
||
function curveSVG(){
|
||
const W=600,H=220,pl=58,pr=14,pt=14,pb=34,lo=100,hi=2000,qs=[];for(let q=lo;q<=hi;q+=10)qs.push(q);
|
||
const ys=qs.map(q=>quote(q).per),top=Math.max(...ys),unit=S.cur==="USD"?THB_USD:1,step=[50,100,200,250,500].find(s=>top/unit/s<=6)||1000,ymax=Math.ceil(top/unit/step)*step*unit;
|
||
const X=q=>pl+(Math.log(q)-Math.log(lo))/(Math.log(hi)-Math.log(lo))*(W-pl-pr),Y=v=>pt+(1-v/ymax)*(H-pt-pb);
|
||
let g="";for(let v=0;v<=ymax+1e-6;v+=step*unit)g+=`<line class="cv-grid" x1="${pl}" x2="${W-pr}" y1="${Y(v)}" y2="${Y(v)}"/><text class="cv-t" x="${pl-6}" y="${Y(v)+4}" text-anchor="end">${money(v,{bare:true,dec:0})}</text>`;
|
||
[100,200,300,500,1000,2000].forEach(q=>g+=`<line class="cv-grid" x1="${X(q)}" x2="${X(q)}" y1="${pt}" y2="${H-pb}"/><text class="cv-t" x="${X(q)}" y="${H-pb+16}" text-anchor="middle">${fmtInt(q)}</text>`);
|
||
const d=qs.map((q,i)=>(i?"L":"M")+X(q).toFixed(1)+" "+Y(ys[i]).toFixed(1)).join(" ");
|
||
const q=Math.min(hi,Math.max(lo,S.qty)),v=quote(q).per,mx=X(q),my=Y(v);
|
||
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block">${g}<text class="cv-t" x="${pl}" y="${H-4}">copies (log scale)</text><text class="cv-t" x="${W-pr}" y="${H-4}" text-anchor="end">per copy, all in, ${S.cur}</text><path class="cv-line" d="${d}"/><circle class="cv-dot" cx="${mx}" cy="${my}" r="5"/><text class="cv-m" x="${mx+(mx>W-170?-9:9)}" y="${my-10}" text-anchor="${mx>W-170?"end":"start"}">${fmtInt(q)} copies: ${money(v)}</text></svg>`;
|
||
}
|
||
|
||
/* ---- renderers ---- */
|
||
function syncSegs(){
|
||
$$(".seg[data-k]").forEach(g=>{const k=g.dataset.k;g.querySelectorAll("button").forEach(b=>b.classList.toggle("on",String(S[k])===b.dataset.v));});
|
||
$("#bGate").disabled=S.size!=="12";
|
||
$("#rowWeight").hidden=S.size!=="12";
|
||
$("#rowColours").hidden=S.colour==="black";
|
||
$("#c2").hidden=!(S.colour==="marbled"||S.colour==="splatter");
|
||
$("#colHint").textContent=S.colour==="solid"?"vinyl colour":"base and second colour";
|
||
}
|
||
function renderNav(){
|
||
const done={1:!!S.visited[2],2:S.gate1,3:!!S.mode,4:S.launched,5:false};
|
||
$$("#steps button").forEach(b=>{const s=+b.dataset.s;b.classList.toggle("on",s===S.step);b.classList.toggle("done",!!done[s]&&s!==S.step);});
|
||
$$("section.step").forEach(x=>x.hidden=+x.dataset.step!==S.step);
|
||
const on=$("#steps button.on"),c=$("#steps .in");
|
||
if(on&&c){const o=on.offsetLeft-c.offsetLeft;if(o<c.scrollLeft||o+on.offsetWidth>c.scrollLeft+c.clientWidth)c.scrollLeft=Math.max(0,o-20);}
|
||
}
|
||
function fileInfo(t){
|
||
if(!t.file)return "<small>typed in</small>";
|
||
const i=t.info||{};if(i.reading)return "<small>reading…</small>";
|
||
if(!i.lossless)return `<small class="st bad">${esc(i.fmt||"?")}: not lossless</small>`;
|
||
let pk="";if(i.scanning)pk=" · scanning";else if(typeof i.peak==="number")pk=" · peak "+(i.peak>0?(20*Math.log10(i.peak)).toFixed(1):"-inf")+" dBFS"+(i.clips?" · clips":"");
|
||
return `<small>${i.fmt} ${i.bits}-bit ${(i.sr/1000).toFixed(i.sr%1000?1:0)} kHz ${i.ch===2?"stereo":i.ch===1?"mono":i.ch+" ch"}${pk}</small>`;
|
||
}
|
||
function renderTracks(){
|
||
const names=sideNames();
|
||
$("#trk").innerHTML=`<thead><tr><th>#</th><th>Title</th><th>Length</th><th>Side</th><th>File</th><th></th></tr></thead><tbody>`+
|
||
S.tracks.map((t,i)=>`<tr data-id="${t.id}"><td>${i+1}</td><td><input type="text" class="tt" value="${esc(t.title)}"></td><td><input type="text" class="tdur" value="${t.sec?mmss(t.sec):""}" placeholder="m:ss"${t.file&&t.info&&t.info.sec?' readonly title="Read from the file"':""}></td><td><select class="tside">${names.map(n=>`<option${n===t.side?" selected":""}>${n}</option>`).join("")}</select></td><td>${fileInfo(t)}</td><td><button type="button" class="x" data-act="rm" title="Remove">×</button></td></tr>`).join("")+"</tbody>";
|
||
}
|
||
function renderSides(){
|
||
const ss=sideStats(),L=ss[0].lim;
|
||
$("#sides").innerHTML=`<p class="hint">${S.size}" at ${rpmT()}${S.loud?", loud or bass-heavy music":""}: comfortable up to ${mmss(L[0])} a side, at most ${mmss(L[1])}. The marker is the comfortable limit; a full bar is the most a side holds.</p>`+
|
||
ss.map(s=>`<div class="side"><div class="row"><span>Side ${s.n} <small>${s.count} track${s.count===1?"":"s"}</small></span><span class="st ${s.st}">${mmss(s.sec)}</span></div><div class="bar"><i class="${s.st}" style="width:${Math.min(100,s.sec/L[1]*100).toFixed(1)}%"></i><b style="left:${(L[0]/L[1]*100).toFixed(1)}%"></b></div></div>`).join("");
|
||
}
|
||
function renderArt(){
|
||
const ic={ok:"✓",warn:"!",bad:"✕",pend:"○"};
|
||
$("#artRes").innerHTML=["jacket","label"].map(k=>{const a=S.art[k];if(!a)return "";const cs=artChecks(k);
|
||
return `<div class="art">${a.url?`<img src="${a.url}" alt="">`:""}<div><b>${ART[k].t}</b> <small>${esc(a.name)}</small><ul>${cs.map(([s,t])=>`<li><span class="st ${s}">${ic[s]}</span> ${t}</li>`).join("")}</ul><button type="button" class="btn sec sm" data-act="artRm" data-k="${k}">Remove</button></div></div>`;}).join("");
|
||
}
|
||
function renderChecks(){
|
||
const ic={ok:"✓",warn:"!",bad:"✕",pend:"○"},L=checkList(),bad=L.filter(x=>x[0]==="bad").length;
|
||
$("#checks").innerHTML=L.map(([s,t,d])=>`<li><span class="st ${s}">${ic[s]}</span><div><b>${t}</b><br><small>${d}</small></div></li>`).join("");
|
||
const b=$("#gate1"),can=!bad&&S.rights&&S.tracks.length>0;
|
||
b.disabled=!can||S.gate1;
|
||
b.textContent=S.gate1?"✓ Master plan approved (gate 1)":"Approve the master plan (gate 1)";
|
||
$("#gate1Msg").textContent=S.gate1?"Recorded as gate 1 of the process flow. Change the tracks or the format and it has to be approved again.":bad?"Fix the items marked ✕ first.":!S.rights?"Confirm the rights first.":"Open items (○) are checked by people after upload; they do not hold up the demo.";
|
||
$("#rights").checked=S.rights;
|
||
}
|
||
function renderOffer(){
|
||
const Q=quote(S.qty);
|
||
const tr=x=>`<tr><td>${x.t}${x.pass!=null?` <span class="pass">↗ partner ${money(x.pass)} + ${pctT(R.handling.v)}</span>`:""}</td><td class="num">${x.u?x.n+" "+x.u:""}</td><td class="num">${money(x.r)}</td><td class="num">${money(x.a)}</td></tr>`;
|
||
$("#offer").innerHTML=`<tbody><tr class="hd"><th>Per title, paid once</th><th class="num">Qty</th><th class="num">Rate</th><th class="num">Amount</th></tr>${Q.T.map(tr).join("")}<tr class="sub"><td colspan="3">Per title</td><td class="num">${money(Q.tSum)}</td></tr>
|
||
<tr class="hd"><th>Per copy</th><th class="num"></th><th class="num">Rate</th><th class="num">Per copy</th></tr>${Q.C.map(tr).join("")}<tr class="sub"><td colspan="3">Per copy ${money(Q.cSum)} × ${fmtInt(S.qty)} copies</td><td class="num">${money(Q.cSum*S.qty)}</td></tr>
|
||
<tr class="tot"><td colspan="3">Order total</td><td class="num">${money(Q.total)}</td></tr><tr class="tot"><td colspan="3">Per copy, all in</td><td class="num">${money(Q.per)}</td></tr></tbody>`;
|
||
let h=0;const key=JSON.stringify([S.size,S.rpm,S.discs,S.weight,S.colour,S.qty,S.inner,S.jacket,S.shrink,S.insert,S.sticker,S.numbered,S.mastering,S.tps]);for(const c of key)h=(h*31+c.charCodeAt(0))>>>0;
|
||
$("#offerHead").textContent="Offer OF-"+h.toString(36).toUpperCase().slice(0,6)+" · price list "+PLV+" · valid until "+fmtD(addDays(TODAY,30))+" · "+descT();
|
||
$("#curve").innerHTML=curveSVG();
|
||
}
|
||
function renderLead(){
|
||
const L=leadInfo(false),ship=[addDays(TODAY,L.tw[0]*7),addDays(TODAY,L.tw[1]*7)];
|
||
$("#leadBox").innerHTML=`<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>${monthRange(ship[0],ship[1])}</b></div>
|
||
<p class="hint">The quote counts from your last approval, because the plant cannot be held to a clock that is waiting 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 are the mature-plant column of the production timeline.</p>
|
||
<details><summary>Stages before your test-pressing approval</summary><table>${L.pre.map(s=>`<tr><td>${s.gate?"◆ ":""}${s.t}</td><td class="num">${s.d[0]} to ${s.d[1]} days</td></tr>`).join("")}</table></details>`;
|
||
}
|
||
function renderChoice(){
|
||
$("#bDirect").disabled=!S.gate1;
|
||
$("#choiceMsg").innerHTML=S.gate1?"":"Approve the master plan in step 2 first: nothing goes into production, or in front of fans, that the plant cannot make. <button type=\"button\" class=\"btn sec sm\" data-act=\"go\" data-s=\"2\">Go to step 2</button>";
|
||
}
|
||
function renderCamp(){
|
||
const E=campEcon(),el=$("#campEcon");$("#daysV").textContent=S.days;
|
||
let msg="";
|
||
if(E.goal==null){el.innerHTML=`<div class="note bad">At ${money(E.p)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the per-copy cost. Raise the fan price.</div>`;}
|
||
else{
|
||
const ns=[...new Set([E.goal,150,200,300,500,1000])].filter(n=>n>=E.goal&&(!S.cap||n<=S.cap)).sort((a,b)=>a-b);
|
||
const rows=ns.map(E.at);const g=E.at(E.goal);
|
||
el.innerHTML=`<div class="kpis"><div><small>Your break-even</small><b>${E.be} fans</b></div><div><small>Plant minimum run</small><b>${E.min} copies</b></div><div><small>Campaign goal</small><b>${E.goal} fans</b></div><div><small>You keep at the goal</small><b>${money(g.per)}</b><small>a copy</small></div></div>
|
||
${S.cap&&S.cap<E.goal?`<div class="note bad">The edition is capped at ${S.cap}, below the goal of ${E.goal}: this campaign cannot succeed.</div>`:""}
|
||
<div class="tscroll"><table><tbody><tr class="hd"><th>Fans</th><th class="num">Pressed</th><th class="num">Fan money after fees${S.vat?" and VAT":""}</th><th class="num">Per title</th><th class="num">Pressing and packaging</th><th class="num">You keep</th><th class="num">Per fan copy</th></tr>
|
||
${rows.map(r=>`<tr><td>${fmtInt(r.n)}${r.n===E.goal?" <small>goal</small>":""}</td><td class="num">${fmtInt(r.run)}</td><td class="num">${money(r.net)}</td><td class="num">${money(-E.tSum)}</td><td class="num">${money(-r.run*r.c)}</td><td class="num"><b>${money(r.keep)}</b></td><td class="num">${money(r.per)}</td></tr>`).join("")}</tbody></table></div>
|
||
<p class="hint">The goal is the larger of the plant's minimum run and your break-even. Payment fees ${pctT(E.fee)} of the fan price${S.vat?", VAT 7/107 of it":""}. Over-subscription is good news: the per-title cost spreads over more copies. ${S.vat?"":"VAT is not taken out here, as in the artist platform example; tick the box above to see it."}</p>`;
|
||
}
|
||
const can=S.gate1&&E.goal!=null&&!(S.cap&&S.cap<E.goal);
|
||
$("#bLaunch").disabled=!can;
|
||
msg=!S.gate1?"Approve the master plan in step 2 first.":!can?"Fix the campaign settings above first.":"Simulated: no page goes live and nobody is charged.";
|
||
$("#launchMsg").textContent=msg;
|
||
}
|
||
function renderAside(){
|
||
const Q=quote(S.qty),L=leadInfo(false),ship=[addDays(TODAY,L.tw[0]*7),addDays(TODAY,L.tw[1]*7)];
|
||
const nx={1:["Next: music and artwork →",2],2:["Next: your offer →",3],3:["Fund it with fans →",4]}[S.step];
|
||
$("#sumCard").innerHTML=`<div class="mock">${mockSVG("ma")}</div><div class="sum-t">${esc(S.title)}</div><div class="hint">${esc(S.artist)} · ${descT()}</div>
|
||
<div class="kv"><span>Direct order</span><b>${fmtInt(S.qty)} copies</b><span>Per copy, all in</span><b>${money(Q.per)}</b><span>Order total</span><b>${money(Q.total)}</b><span>Ordered today, ships</span><b>${monthRange(ship[0],ship[1])}</b><span>Master plan</span><b class="${S.gate1?"okc":"mutec"}">${S.gate1?"approved":"not approved yet"}</b></div>
|
||
<div class="navb">${S.step>1?`<button type="button" class="btn sec" data-act="go" data-s="${S.step-1}">← Back</button>`:"<span></span>"}${nx?`<button type="button" class="btn" data-act="go" data-s="${nx[1]}">${nx[0]}</button>`:""}</div>`;
|
||
}
|
||
|
||
/* ---- step 5: campaign simulation and production ---- */
|
||
let s5mode=null,TIMER=null,AU=null,AUid=null,AUt=null;
|
||
function renderS5(){
|
||
const el=$("#s5"),m=S.mode||"none";
|
||
if(m!==s5mode){s5mode=m;
|
||
if(m==="none")el.innerHTML=`<div class="card"><h2>Nothing running yet</h2><p>Choose in step 3 how to pay for the record: a direct order, or a campaign your fans fund (set up in step 4).</p><button type="button" class="btn sec" data-act="go" data-s="3">Go to the offer</button></div>`;
|
||
else if(m==="direct")el.innerHTML=`<div class="card"><h2>Direct order</h2><div id="dirSum"></div></div><div id="tracker"></div>`;
|
||
else el.innerHTML=`<div class="grid2"><div class="card"><h2>What fans see</h2><div class="phone" id="fanPage"></div><p class="hint" style="text-align:center">The real page comes in Thai and English.</p></div>
|
||
<div><div class="card"><h2>Run the campaign (simulated)</h2><div class="seg" 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 class="row simrow"><button type="button" class="btn sec" data-act="play" id="playBtn"></button><input type="range" id="simDay" min="0" step="0.1" aria-label="Campaign day"><span id="dayLbl" class="hint"></span></div><div id="money"></div></div><div id="outcome"></div></div></div><div id="tracker"></div>`;
|
||
syncSegs();
|
||
}
|
||
updateS5();
|
||
}
|
||
function trackerHTML(){
|
||
const P=S.prod;if(!P)return "";
|
||
const rows=P.rows.map((s,i)=>{const st=i<P.idx?"done":i===P.idx?"now":"next";
|
||
const act=i===P.idx?(s.gate?`<button type="button" class="btn sm" data-act="adv">Approve, as the artist</button>`:`<button type="button" class="btn sec sm" data-act="adv">Next update ▸</button>`):"";
|
||
return `<li class="${st}${s.gate?" gate":""}"><span class="dot"></span><div><b>${s.t}</b><br><small>${i<P.idx?"done "+fmtD(s.date):"planned "+fmtD(s.date)}${s.gate&&i===P.idx?" · waiting for you":""}</small></div>${act}</li>`;}).join("");
|
||
const feed=P.events.slice().reverse().map(e=>`<li><small>${fmtD(e.date)}</small>${e.text}</li>`).join("");
|
||
return `<div class="grid2"><div class="card"><h2>Production, ${fmtInt(P.run)} copies</h2><ol class="tl">${rows}</ol>${P.idx>=P.rows.length?`<p class="okc"><b>✓ Delivered.</b></p>`:""}<p class="hint">Dates use the midpoints of the production timeline. The three diamonds are your approvals: the plant waits for them. The press slot is the queue and buffer the quote holds, because lead time is a capacity decision.</p></div>
|
||
<div class="card"><h2>${P.origin==="direct"?"Your order updates":"Updates fans see on the campaign page"}</h2><ul class="feed">${feed}</ul><p class="hint">In the real platform these come from the factory's own event log, so the progress is the plant's record, not a status someone remembers to update.</p></div></div>`;
|
||
}
|
||
function newProd(origin,start,fans){
|
||
const L=leadInfo(true),st=STG.filter(s=>s.id!=="intake"&&s.id!=="g1"&&(!s.opt||S[s.opt]));
|
||
const mid=s=>(s.d[0]+s.d[1])/2,after=st.filter(s=>["run","fin","ship"].includes(s.id)).reduce((a,s)=>a+mid(s),0),qMid=(L.qw[0]+L.qw[1])/2*5;
|
||
let cur=new Date(start);
|
||
const rows=st.map(s=>{const d=s.id==="queue"?Math.max(0,qMid-after):mid(s);cur=addWD(cur,d);return Object.assign({},s,{date:new Date(cur)});});
|
||
const run=origin==="direct"?S.qty:origin==="minimum"?Math.max(fans,R.minRun.v)+S.artistCopies:fans+S.artistCopies;
|
||
S.prod={origin,fans,run,rows,idx:0,events:[{date:new Date(start),text:origin==="direct"?"Order confirmed and deposit received.":origin==="minimum"?"Going ahead at the "+R.minRun.v+"-copy minimum with "+fans+" fans.":"Funded by "+fans+" fans. The campaign is now a production order."}]};
|
||
}
|
||
function advance(){const P=S.prod;if(!P||P.idx>=P.rows.length)return;const s=P.rows[P.idx],u=UPD[s.id];P.events.push({date:s.date,text:typeof u==="function"?u(P):u});P.idx++;updateS5();}
|
||
function stopSim(){if(TIMER){clearInterval(TIMER);TIMER=null;}}
|
||
function resetSim(){stopSim();S.simDay=0;S.outcome=null;S.prod=S.mode==="campaign"?null:S.prod;S.outcomeAct=null;}
|
||
function resetCampaign(){stopSim();S.mode=null;S.launched=false;S.simDay=0;S.outcome=null;S.outcomeAct=null;S.prod=null;}
|
||
function revokePlan(){S.gate1=false;resetCampaign();}
|
||
function checkEnd(){
|
||
if(S.simDay>=S.days){if(!S.outcome){const E=campEcon(),P=finalPledges(E);S.finalFans=P;S.outcome=P>=E.goal?"funded":"missed";if(S.outcome==="funded")newProd("campaign",addDays(S.launchDate,S.days),P);}}
|
||
else if(S.outcome){S.outcome=null;S.prod=null;S.outcomeAct=null;}
|
||
}
|
||
function toggleSim(){
|
||
if(TIMER){stopSim();updateS5();return;}
|
||
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();updateS5();},100);
|
||
updateS5();
|
||
}
|
||
function updateS5(){
|
||
if(S.mode==="direct"){
|
||
const Q=quote(S.qty);
|
||
$("#dirSum").innerHTML=`<div class="kv"><span>${fmtInt(S.qty)} copies, ${descT()}</span><b>${money(Q.total)}</b><span>Deposit now, ${pctT(R.deposit.v)} (placeholder)</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">Deposit and balance terms are not set yet. A label with an account gets its own price-list version and approves the gates in the customer portal.</p>`;
|
||
$("#tracker").innerHTML=trackerHTML();return;
|
||
}
|
||
if(S.mode!=="campaign")return;
|
||
const E=campEcon(),P=finalPledges(E),ended=S.simDay>=S.days,n=ended?P:Math.round(P*frac(S.simDay/S.days)),goal=E.goal||0;
|
||
const close=addDays(S.launchDate,S.days),L=leadInfo(true),ship=[addDays(close,L.tw[0]*7),addDays(close,L.tw[1]*7)];
|
||
const sl=$("#simDay");sl.max=S.days;if(document.activeElement!==sl)sl.value=S.simDay;
|
||
$("#dayLbl").textContent="Day "+Math.floor(S.simDay)+" of "+S.days;
|
||
$("#playBtn").textContent=TIMER?"❚❚ Pause":ended?"↺ Replay":"▶ Play";
|
||
const funded=ended&&n>=goal,soldOut=S.cap&&n>=S.cap;
|
||
const status=!ended?`Pre-orders close on ${fmtD(close)}. If fewer than ${goal} fans have pre-ordered by then, everyone is refunded automatically.`
|
||
:funded?`<b class="okc">✓ Funded by ${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 shortfall.`;
|
||
const tracks=sideNames().map(sd=>S.tracks.filter(t=>t.side===sd).map(t=>`<li><span class="sd">${sd}</span><button type="button" class="pv${AUid===t.id?" on":""}" data-act="pv" data-id="${t.id}"${t.url?' title="Play a 30 second preview from your file"':' disabled title="Previews are rendered by the plant from the master after upload"'}>${AUid===t.id?"■":"▶"}</button><span class="tn">${esc(t.title)}</span><small>${mmss(t.sec)}</small></li>`).join("")).join("");
|
||
$("#fanPage").innerHTML=`<div class="mock">${mockSVG("mf")}</div><div class="fp-t">${esc(S.title)}</div><div class="fp-a">${esc(S.artist)}</div><div class="hint">${descT()}</div>
|
||
<div class="fp-p">${money(S.fanPrice)} <small>+ shipping at cost</small></div>${S.cap?`<div class="hint">Limited, numbered edition of ${fmtInt(S.cap)}</div>`:""}
|
||
<div class="bar big"><i class="${ended&&!funded?"bad":"ok"}" style="width:${goal?Math.min(100,n/goal*100).toFixed(1):0}%"></i></div>
|
||
<div class="row" style="margin:2px 0 6px"><span><b>${fmtInt(n)}</b> of ${fmtInt(goal)} fans${n>goal?` <small>${Math.round(n/goal*100)} %</small>`:""}</span><span class="hint">${ended?"closed":Math.max(0,Math.ceil(S.days-S.simDay))+" days left"}</span></div>
|
||
<p class="fp-s">${status}</p><div class="kv"><span>Estimated to ship</span><b>${monthRange(ship[0],ship[1])}</b></div>
|
||
<button type="button" class="btn wide" disabled>Pre-order (demo)</button>
|
||
<h4>Tracks</h4><ol class="fp-tr">${tracks}</ol>
|
||
<div class="share"><button type="button" class="btn sec sm" data-act="copy">Copy link</button><button type="button" class="btn sec sm" disabled title="Shares to LINE at launch">LINE</button><button type="button" class="btn sec sm" disabled title="A QR code for gigs and merch tables, made at launch">QR code</button></div>
|
||
<p class="fp-f">Made and sold by CNX Vinyl, Chiang Mai. Your payment is held in a separate account until the campaign succeeds, and refunded automatically if it does not.</p>`;
|
||
const held=n*S.fanPrice;
|
||
$("#money").innerHTML=`<div class="kv"><span>Fans so far</span><b>${fmtInt(n)}</b><span>Held in the separate account</span><b>${money(held)}</b><span>of which payment fees, ${pctT(R.fee.v)}</span><b>${money(held*R.fee.v)}</b><span>This scenario ends with</span><b>${fmtInt(P)} fans</b></div><p class="hint">Play runs the ${S.days} days in about 8 seconds; drag the slider to any day.</p>`;
|
||
let oc="";
|
||
if(ended&&goal){
|
||
if(funded){const A=E.at(n);
|
||
oc=`<div class="card okb"><h2>Funded</h2><p>${fmtInt(n)} fans pre-ordered. Their money leaves the separate account for production, and the campaign becomes a production order (below).</p><div class="kv"><span>Fan money after fees${S.vat?" and VAT":""}</span><b>${money(A.net)}</b><span>Per title services</span><b>${money(-E.tSum)}</b><span>Pressing and packaging, ${fmtInt(A.run)} copies${S.artistCopies?" (incl. "+S.artistCopies+" for you)":""}</span><b>${money(-A.run*A.c)}</b><span>You keep</span><b>${money(A.keep)}</b></div><p class="hint">When the margin is paid out to the artist is not decided yet.</p></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="card"><h2>Refunded</h2><p>${fmtInt(n)} refunds of ${money(S.fanPrice)} went from the separate account 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" data-act="retry">Run it again</button></div>`;
|
||
else if(S.outcomeAct==="min")oc=`<div class="card okb"><h2>Going ahead at the minimum</h2><p>${diff<0?"You paid the shortfall of <b>"+money(-diff)+"</b>":"The fans' money covered the minimum run; you keep <b>"+money(diff)+"</b>"}, and you receive the ${spare} copies no fan claimed${S.artistCopies?" plus your "+S.artistCopies:""}.</p></div>`;
|
||
else oc=`<div class="card warnb"><h2>Goal missed: ${fmtInt(n)} of ${fmtInt(goal)}</h2><p>Two ways out; the artist chooses.</p><div class="opts"><div class="opt"><h3>Refund everyone</h3><p>Automatic, from the separate account. Nothing is pressed; the files stay in your vault for a second attempt.</p><button type="button" class="btn sec" data-act="refund">Refund all ${fmtInt(n)} fans</button></div><div class="opt"><h3>Go ahead at the ${E.min}-copy minimum</h3><p>${diff<0?"You pay the shortfall of <b>"+money(-diff)+"</b>":"Costs you nothing: the fans' money covers the minimum run and you keep "+money(diff)}. You receive the ${spare} copies no fan claimed${S.artistCopies?" plus your "+S.artistCopies:""}.</p><button type="button" class="btn" data-act="goMin">Go ahead</button></div></div></div>`;
|
||
}
|
||
}
|
||
$("#outcome").innerHTML=oc;
|
||
$("#tracker").innerHTML=trackerHTML();
|
||
}
|
||
function preview(id){
|
||
const t=S.tracks.find(x=>x.id===id);
|
||
if(AU){AU.pause();clearTimeout(AUt);const was=AUid;AU=null;AUid=null;if(was===id){updateS5();return;}}
|
||
if(!t||!t.url)return;
|
||
AU=new Audio(t.url);AUid=id;AU.play().catch(()=>{});AUt=setTimeout(()=>{if(AU){AU.pause();AU=null;AUid=null;updateS5();}},30000);
|
||
AU.addEventListener("ended",()=>{AU=null;AUid=null;updateS5();});updateS5();
|
||
}
|
||
|
||
/* ---- price list card ---- */
|
||
function rateText(k){
|
||
const v=R[k].v;
|
||
if(k==="press")return `12" ${v[12]} · 10" ${v[10]} · 7" ${v[7]} THB`;
|
||
if(k==="colour")return `solid ${v.solid} · marbled ${v.marbled} · splatter ${v.splatter} THB`;
|
||
if(k==="inner")return `paper ${v.paper[12]} / ${v.paper[10]} / ${v.paper[7]}, poly-lined ${v.poly[12]} / ${v.poly[10]} / ${v.poly[7]} THB (12" / 10" / 7")`;
|
||
if(k==="jacket")return `printed 12" ${v.printed[12].join(" / ")}, 10" ${v.printed[10].join(" / ")}, 7" ${v.printed[7].join(" / ")}; gatefold 12" ${v.gatefold[12].join(" / ")}; plain 12" ${v.plain[12][0]}, 10" ${v.plain[10][0]}, 7" ${v.plain[7][0]} THB`;
|
||
if(k==="handling"||k==="fee"||k==="deposit")return pctT(v);
|
||
if(k==="minRun")return v+" copies";
|
||
return fmtInt(v)+" THB";
|
||
}
|
||
function renderPL(){
|
||
const kb={doc:"doc",team:"team",est:"estimate"};
|
||
const lim=[["12\"","33"],["12\"","45"],["10\"","33"],["10\"","45"],["7\"","33"],["7\"","45"]].map(([s,r])=>{const v=LIM[s.replace("\"","")][r];return `<tr><td>${s}, ${r==="33"?"33⅓":"45"} rpm</td><td class="num">${mmss(v[0]*60)}</td><td class="num">${mmss(v[1]*60)}</td></tr>`;}).join("");
|
||
$("#plCard").innerHTML=`<h2>Price list ${PLV}, and where every number comes from</h2>
|
||
<p class="hint">The planning docs give the price per title (19,000 to 57,000 THB, midpoint 38,000) and per copy (115 to 150 THB, midpoint 132) as working assumptions at Western benchmark rates. This demo splits them into lines; every split is an estimate. The Thai market pays far more today (650 to 950 THB a copy at 300 copies), so do not read the price level off this page: the real price list follows market validation. Rates are in THB; USD at 1 USD = 33 THB.</p>
|
||
<div class="tscroll"><table><tbody><tr class="hd"><th>Item</th><th>Rate</th><th>Unit</th><th>Kind</th><th>Basis</th></tr>${Object.keys(R).map(k=>`<tr><td>${R[k].t}</td><td>${rateText(k)}</td><td><small>${R[k].u}</small></td><td><span class="badge b-${R[k].k}">${kb[R[k].k]}</span></td><td><small>${R[k].src}</small></td></tr>`).join("")}</tbody></table></div>
|
||
<h2 style="margin-top:18px">Side length per format</h2>
|
||
<div class="tscroll"><table style="max-width:460px"><tbody><tr class="hd"><th>Format</th><th class="num">Comfortable</th><th class="num">At most</th></tr>${lim}</tbody></table></div>
|
||
<p class="hint">Published cutting-room guidance, checked October 2026: microcut.studio's format guide (lower end of each range); Diggers Factory lists hard maxima of 22, 12, 17, 10, 6 and 5 minutes and A to Z Media 14 to 18 minutes as ideal on a 12" LP. Loud or bass-heavy music: limits times 0.6, from Diggers Factory's dance-music figures. The engineer check decides, not this table.</p>
|
||
<h2 style="margin-top:18px">Lead time</h2>
|
||
<p class="hint">Stage durations: mature-plant column of the production timeline. Quote: 7 to 9 weeks for a new title, full service, mature plant, counted from test-pressing approval as the timeline says a quote should be made; 1 to 2 weeks less without a printed jacket. Campaign ship month: campaign close, plus the stages before test-pressing approval, plus the quote.</p>
|
||
<h2 style="margin-top:18px">Not in the demo yet</h2>
|
||
<p class="hint">Freight and per-fan postage (charged at cost), VAT on direct orders, the "test pressing first" campaign, repress pre-orders, the Thai version, the interest list and anonymous configuration logging for market research, the payment provider (Opn/Omise, 2C2P or a bank gateway) and the platform's name, which is still an open decision.</p>`;
|
||
}
|
||
|
||
function renderAll(){syncSegs();renderNav();renderSides();renderArt();renderChecks();renderOffer();renderLead();renderChoice();renderCamp();renderS5();renderAside();}
|
||
function go(s){if(s<1||s>5)return;S.step=s;S.visited[s]=true;renderAll();const top=$("header").offsetHeight;if(window.scrollY>top)window.scrollTo(0,top);}
|
||
|
||
/* ---- events ---- */
|
||
const PLAN_KEYS=["size","rpm","discs","loud"],RUN_KEYS=["weight","colour","inner","jacket","mastering"];
|
||
function setK(k,v){
|
||
if(S[k]===v)return;S[k]=v;
|
||
if(k==="size"&&v!=="12"&&S.jacket==="gatefold")S.jacket="printed";
|
||
if(k==="scen"){resetSim();renderAll();return;}
|
||
if(k==="discs"){autoSplit();}
|
||
if(PLAN_KEYS.includes(k)){revokePlan();}
|
||
else if(RUN_KEYS.includes(k)&&S.mode)resetCampaign();
|
||
if(k==="discs"||k==="size")renderTracks();
|
||
renderAll();
|
||
}
|
||
const ACT={
|
||
go:b=>go(+b.dataset.s),
|
||
rm:b=>{const t=trkOf(b);S.tracks=S.tracks.filter(x=>x!==t);S.demoTracks=false;revokePlan();renderTracks();renderAll();},
|
||
add:()=>{S.tracks.push({id:++tid,title:"New track",sec:180,side:sideNames().slice(-1)[0],file:null,info:null,url:null});S.demoTracks=false;revokePlan();renderTracks();renderAll();},
|
||
split:()=>{autoSplit();revokePlan();renderTracks();renderAll();},
|
||
gate1:()=>{S.gate1=true;renderAll();},
|
||
artRm:b=>{S.art[b.dataset.k]=null;renderAll();},
|
||
direct:()=>{if(!S.gate1)return go(2);resetCampaign();S.mode="direct";newProd("direct",TODAY,0);go(5);},
|
||
launch:()=>{if(!S.gate1)return go(2);resetCampaign();S.mode="campaign";S.launched=true;S.launchDate=new Date(TODAY);go(5);},
|
||
play:()=>toggleSim(),
|
||
adv:()=>advance(),
|
||
refund:()=>{S.outcomeAct="refund";updateS5();},
|
||
goMin:()=>{S.outcomeAct="min";newProd("minimum",addDays(S.launchDate,S.days),S.finalFans);updateS5();},
|
||
retry:()=>{resetSim();updateS5();},
|
||
pv:b=>preview(+b.dataset.id),
|
||
copy:()=>{const slug=S.title.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");const u="https://vinyl.example/c/"+slug;(navigator.clipboard?navigator.clipboard.writeText(u):Promise.reject()).then(()=>toast("Copied (example link): "+u),()=>toast(u));}
|
||
};
|
||
document.addEventListener("click",e=>{
|
||
const b=e.target.closest("button");if(!b||b.disabled)return;
|
||
const g=b.closest(".seg[data-k]");
|
||
if(g){const v=b.dataset.v;setK(g.dataset.k,g.hasAttribute("data-num")?Number(v):g.hasAttribute("data-bool")?v==="true":v);return;}
|
||
const a=b.dataset.act;if(a&&ACT[a])ACT[a](b);
|
||
});
|
||
document.addEventListener("input",e=>{
|
||
const t=e.target,id=t.id;
|
||
if(id==="qtyR"){S.qty=+t.value;$("#qty").value=S.qty;if(S.mode==="direct")resetCampaign();renderAll();}
|
||
else if(id==="qty"){const v=Math.round(+t.value);if(v>=100&&v<=5000){S.qty=v;$("#qtyR").value=Math.min(2000,v);if(S.mode==="direct")resetCampaign();renderAll();}}
|
||
else if(id==="tps"){const v=Math.round(+t.value);if(v>=0&&v<=10){S.tps=v;if(S.mode)resetCampaign();renderAll();}}
|
||
else if(id==="c1"||id==="c2"){S[id]=t.value;renderAll();}
|
||
else if(id==="artist"||id==="title"){S[id]=t.value;renderAll();}
|
||
else if(t.classList.contains("tt")){trkOf(t).title=t.value;S.demoTracks=false;if(S.mode==="campaign")updateS5();}
|
||
else if(id==="fanPrice"){const v=+t.value;if(v>=100&&v<=20000){S.fanPrice=v;if(S.mode==="campaign")resetCampaign();renderAll();}}
|
||
else if(id==="days"){S.days=+t.value;if(S.mode==="campaign")resetCampaign();renderAll();}
|
||
else if(id==="cap"){const v=Math.max(0,Math.round(+t.value)||0);S.cap=v;if(S.mode==="campaign")resetCampaign();renderAll();}
|
||
else if(id==="acop"){const v=Math.max(0,Math.round(+t.value)||0);S.artistCopies=v;if(S.mode==="campaign")resetCampaign();renderAll();}
|
||
else if(id==="simDay"){stopSim();S.simDay=+t.value;checkEnd();updateS5();}
|
||
});
|
||
document.addEventListener("change",e=>{
|
||
const t=e.target;
|
||
if(t.id==="qty"){S.qty=Math.min(5000,Math.max(100,Math.round((+t.value||100)/10)*10));t.value=S.qty;$("#qtyR").value=Math.min(2000,S.qty);renderAll();}
|
||
else if(t.id==="tps"){S.tps=Math.min(10,Math.max(0,Math.round(+t.value)||0));t.value=S.tps;renderAll();}
|
||
else if(t.classList.contains("tdur")){const v=parseDur(t.value);if(v==null||v<=0){t.classList.add("err");return;}t.classList.remove("err");trkOf(t).sec=v;t.value=mmss(v);S.demoTracks=false;revokePlan();renderAll();}
|
||
else if(t.classList.contains("tside")){trkOf(t).side=t.value;revokePlan();renderAll();}
|
||
else if(t.dataset.b){S[t.dataset.b]=t.checked;if(S.mode)resetCampaign();renderAll();}
|
||
else if(t.id==="rights"){S.rights=t.checked;if(!t.checked)revokePlan();renderAll();}
|
||
else if(t.id==="vat"){S.vat=t.checked;if(S.mode==="campaign")resetCampaign();renderAll();}
|
||
});
|
||
|
||
/* ---- drag and drop ---- */
|
||
window.addEventListener("dragover",e=>e.preventDefault());
|
||
window.addEventListener("drop",e=>e.preventDefault());
|
||
function dropZone(el,input,handler){
|
||
["dragenter","dragover"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.add("over");}));
|
||
["dragleave","drop"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.remove("over");}));
|
||
el.addEventListener("drop",e=>handler(Array.from(e.dataTransfer.files||[])));
|
||
input.addEventListener("change",()=>{handler(Array.from(input.files||[]));input.value="";});
|
||
}
|
||
async function handleAudio(files){
|
||
if(!files.length)return;
|
||
files.sort((a,b)=>a.name.localeCompare(b.name,undefined,{numeric:true}));
|
||
if(S.demoTracks){S.tracks=[];S.demoTracks=false;}
|
||
const added=files.map(f=>{const t={id:++tid,title:f.name.replace(/\.[^.]+$/,"").replace(/^\d+\s*[-._ ]\s*/,""),sec:0,side:"A",file:f,info:{reading:true},url:URL.createObjectURL(f)};S.tracks.push(t);return t;});
|
||
revokePlan();renderTracks();renderAll();
|
||
for(const t of added){try{t.info=await audioInfo(t.file);}catch(err){t.info={fmt:"?",lossless:false,err:String(err)};}if(t.info.sec)t.sec=t.info.sec;if(!t.info.lossless){URL.revokeObjectURL(t.url);t.url=null;}}
|
||
autoSplit();renderTracks();renderAll();
|
||
for(const t of added){if(!t.info.lossless)continue;t.info.scanning=true;renderTracks();renderChecks();await scanPeak(t);t.info.scanning=false;renderTracks();renderAll();}
|
||
}
|
||
async function handleArt(k,files){
|
||
const f=files.find(f=>/^image\//.test(f.type)||/\.(jpe?g|png|tiff?|webp)$/i.test(f.name));if(!f)return;
|
||
try{S.art[k]=await readImage(f);}catch(err){S.art[k]={name:f.name,err:"This browser cannot read the file; the real platform checks TIFF and PDF after upload."};}
|
||
if(S.mode)resetCampaign();renderAll();
|
||
}
|
||
dropZone($("#dropAudio"),$("#fileAudio"),handleAudio);
|
||
dropZone($("#dropJacket"),$("#fileJacket"),f=>handleArt("jacket",f));
|
||
dropZone($("#dropLabel"),$("#fileLabel"),f=>handleArt("label",f));
|
||
|
||
/* ---- theme ---- */
|
||
(function(){
|
||
const b=$("#themeBtn"),de=document.documentElement;
|
||
function set(t,save){de.setAttribute("data-theme",t);if(save){try{localStorage.setItem("cnx-theme",t)}catch(e){}}b.textContent=t==="light"?"☾ Dark mode":"☀ Light mode";b.title="Switch to "+(t==="light"?"dark":"light")+" mode";}
|
||
set(de.getAttribute("data-theme")||"dark",false);
|
||
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);});
|
||
})();
|
||
|
||
/* ---- init ---- */
|
||
$("#qty").value=S.qty;$("#qtyR").value=S.qty;$("#tps").value=S.tps;$("#c1").value=S.c1;$("#c2").value=S.c2;
|
||
$("#artist").value=S.artist;$("#title").value=S.title;$("#fanPrice").value=S.fanPrice;$("#days").value=S.days;$("#cap").value=S.cap;$("#acop").value=S.artistCopies;
|
||
$$("input[data-b]").forEach(c=>c.checked=!!S[c.dataset.b]);
|
||
renderTracks();renderPL();renderAll();
|
||
window.__ao={S,quote,campEcon,leadInfo,sideStats,checkList,go,ACT};
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|