Deploy test site / deploy (push) Successful in 1m2s
User, 20 Oct 2026: can the deck also be made in a light and dark mode; the new order tool should also offer fan funding, so the platform screenshots match and use the same calculator; the main investor holds at least 60 %, so 40 % is for sale. Order tool (docs/order.html, /demos/order/): - New step 15 "How to pay": pay the plant directly (deposit) or let fans fund it; step 16 becomes "Review & launch" with the campaign simulation and its three outcomes. - Campaign maths ported from make-a-record (fan price, length, cap, own copies, VAT; fans needed = max(100, break-even); keep at the goal and with more fans; price hint); R gains fee (3.5 %), VAT and campaign length with sources; no price changed. - Quote card shows fan price, fans needed and the artist's keep on the fan route; route and campaign in the #q= hash; deep links #s=<step>&p=<preset>&route=fans. - Parity with make-a-record: 206/206 checks over 40 configurations; default quotes unchanged (37,836 / 217 / 102,936 THB; partner mastering 43,676). Investor deck (docs/pitch/): - Light and dark theme like the other site pages (cnx-theme, T key and button), presenter and overview follow; charts from CSS variables; text pairs at WCAG AA in both themes; tool screenshots in both themes. - Two PDFs: dark and light (print-friendly), each ~1.5 MB. - Platform slide: both pictures from the order tool (Splatter 12" order step and its fan-funding view: 100 fans at 1,200 THB, break-even 46, ~480 THB a copy kept). - BOI and financing slide: shareholding bar, lead investor at least 60 %, at most 40 % for co-investors (~32M / ~22M THB at the ~54M equity floor, arithmetic only); the ask offers up to 40 %. Docs: artist-platform.md (fan funding in the order tool), README and landing page entries.
2072 lines
224 KiB
HTML
2072 lines
224 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 · Order a record (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;
|
||
--act: #f6c945; --onact: #231c05; --actbg: rgba(246,201,69,0.10); --actring: rgba(246,201,69,0.22);
|
||
--badbg: rgba(255,107,107,0.10); --warnbg: rgba(255,181,71,0.10); --okbg: rgba(61,220,132,0.10);
|
||
--st1: #1b2540; --st2: #070b14; --stx: rgba(214,222,235,0.55); --glass: rgba(15,22,38,0.88);
|
||
}
|
||
:root { color-scheme: dark; }
|
||
html[data-theme=light] {
|
||
color-scheme: light;
|
||
--bg: #f4f6fa; --panel: #ffffff; --panel2: #f3f5f9; --stroke: #d3dbe7; --line: #e6ebf2;
|
||
--fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --warn: #b26f00; --bad: #c62828; --info: #2f7de1;
|
||
--input: #ffffff; --onacc: #ffffff;
|
||
--bv: #1f5f9e; --bvb: #9cc0e6; --bd: #1e7a45; --bdb: #9fd3b3; --be: #8a5a00; --beb: #e3c27a;
|
||
--act: #e9b10f; --onact: #241c02; --actbg: rgba(246,201,69,0.16); --actring: rgba(233,177,15,0.25);
|
||
--badbg: rgba(198,40,40,0.07); --warnbg: rgba(255,193,7,0.13); --okbg: rgba(14,143,79,0.08);
|
||
--st1: #f8f3ea; --st2: #e3d9c6; --stx: rgba(60,50,30,0.6); --glass: rgba(255,255,255,0.92);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
[hidden] { display: none !important; }
|
||
html, body { height: 100%; }
|
||
body { margin: 0; background: var(--bg); color: var(--fg); font: 14.5px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; overflow: hidden; }
|
||
a { color: var(--accent); }
|
||
button { font: inherit; }
|
||
/* top bar */
|
||
header.top { flex: none; height: 52px; display: flex; align-items: center; gap: 12px; padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--stroke); z-index: 30; }
|
||
.brand { display: flex; align-items: center; gap: 9px; font-size: 16px; white-space: nowrap; }
|
||
.brand svg { flex: none; }
|
||
.brand b { letter-spacing: 0.02em; }
|
||
.brand span { color: var(--act); font-weight: 700; text-transform: uppercase; font-size: 12.5px; letter-spacing: 0.12em; border: 1px solid var(--act); border-radius: 4px; padding: 1px 6px; }
|
||
.brand em { font-style: normal; color: var(--mute); font-size: 12.5px; }
|
||
.prefs { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||
.seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--stroke); border-radius: 7px; overflow: hidden; }
|
||
.seg button { background: transparent; color: var(--mute); border: 0; padding: 6px 11px; font-size: 13.5px; cursor: pointer; }
|
||
.seg button + button { border-left: 1px solid var(--stroke); }
|
||
.seg button.on { background: var(--actbg); color: var(--fg); box-shadow: inset 0 -2px 0 var(--act); }
|
||
.seg button:disabled { opacity: 0.38; cursor: not-allowed; }
|
||
.seg.cmp button { padding: 4px 9px; font-size: 12.5px; }
|
||
.themebtn { background: var(--panel2); color: var(--fg); border: 1px solid var(--stroke); border-radius: 7px; padding: 5px 10px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||
.themebtn:hover { border-color: var(--act); }
|
||
/* app shell: rail | panel | stage */
|
||
.app { flex: 1; min-height: 0; display: grid; grid-template-columns: 250px 440px minmax(0, 1fr); }
|
||
.app.closed { grid-template-columns: 250px minmax(0, 1fr); }
|
||
.app.closed .panel { display: none; }
|
||
.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 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.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; }
|
||
.ri.vis { color: var(--fg); }
|
||
.ri.vis .rt small { opacity: 1; }
|
||
.ri.done .rn { background: var(--accent); color: var(--onacc); }
|
||
.ri.done .ric { color: var(--accent); }
|
||
.ri.on { background: var(--act); border-color: var(--act); color: var(--onact); }
|
||
.ri.on .rn { background: rgba(0,0,0,0.14); color: var(--onact); }
|
||
.ri.on .ric, .ri.on .rch, .ri.on .rt small { color: var(--onact); opacity: 0.85; }
|
||
.ri.miss { background: var(--badbg); border-color: var(--bad); color: var(--bad); }
|
||
.ri.miss .rn { background: var(--bad); color: #fff; }
|
||
.ri.miss .ric { color: var(--bad); }
|
||
.ri.miss.on { background: var(--act); color: var(--onact); }
|
||
/* step panel */
|
||
.panel { min-height: 0; display: flex; flex-direction: column; background: var(--panel); border-right: 1px solid var(--stroke); z-index: 5; }
|
||
.ph { flex: none; display: flex; align-items: center; gap: 10px; margin: 12px 18px 0; padding-bottom: 10px; border-bottom: 2px solid var(--act); }
|
||
.ph .pn { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--act); color: var(--onact); font-weight: 700; font-size: 13.5px; flex: none; }
|
||
.ph .pic { width: 20px; height: 20px; color: var(--mute); flex: none; }
|
||
.ph h2 { margin: 0; font-size: 19px; flex: 1; min-width: 0; }
|
||
.px { background: transparent; border: 0; color: var(--mute); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
|
||
.px:hover { color: var(--fg); background: var(--panel2); }
|
||
.pb { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 18px 18px; }
|
||
.pf { flex: none; padding: 10px 18px 10px; border-top: 1px solid var(--line); }
|
||
.nextb { width: 100%; background: var(--accent); color: var(--onacc); border: 0; border-radius: 9px; padding: 11px 14px; font-weight: 700; font-size: 15px; cursor: pointer; }
|
||
.nextb:hover { filter: brightness(1.07); }
|
||
.pf .row2 { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 7px; font-size: 12.5px; }
|
||
.pf .row2 .linkb { white-space: nowrap; }
|
||
.linkb { background: transparent; border: 0; color: var(--accent); cursor: pointer; padding: 0; font-size: 13px; text-decoration: underline; text-underline-offset: 2px; }
|
||
.linkb:disabled { color: var(--mute); text-decoration: none; cursor: not-allowed; }
|
||
.foot { color: var(--mute); font-size: 11.5px; }
|
||
.expl { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; color: var(--mute); font-size: 13.5px; margin: 2px 0 12px; }
|
||
.expl b { color: var(--fg); font-weight: 600; }
|
||
h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--mute); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
h4 .badge { text-transform: none; letter-spacing: 0; }
|
||
p { margin: 8px 0; }
|
||
.hint, small { color: var(--mute); font-size: 12.5px; }
|
||
.hint { margin: 6px 0; }
|
||
/* option tiles */
|
||
.tiles { display: grid; grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); gap: 9px; margin: 8px 0; }
|
||
.tiles.c2 { --cols: 2; } .tiles.c4 { --cols: 4; }
|
||
.tile { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 86px; padding: 11px 7px 9px; background: var(--panel2); border: 1.5px solid var(--stroke); border-radius: 11px; color: var(--fg); cursor: pointer; text-align: center; line-height: 1.25; }
|
||
.tile:hover { border-color: var(--act); }
|
||
.tile.on { border-color: var(--act); background: var(--actbg); box-shadow: 0 0 0 3px var(--actring); }
|
||
.tile.on::after { content: "\2713"; position: absolute; top: 5px; right: 7px; width: 17px; height: 17px; border-radius: 50%; background: var(--act); color: var(--onact); font-size: 11px; font-weight: 700; display: grid; place-items: center; }
|
||
.tile b { font-size: 14px; font-weight: 650; }
|
||
.tile small { font-size: 11.5px; }
|
||
.tile .pr { font-size: 11.5px; color: var(--accent); font-variant-numeric: tabular-nums; }
|
||
.tile:disabled { opacity: 0.42; cursor: not-allowed; filter: grayscale(0.8); }
|
||
.tile:disabled:hover { border-color: var(--stroke); }
|
||
.tile .tag { position: absolute; top: 5px; left: 7px; font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--mute); border: 1px solid var(--stroke); border-radius: 4px; padding: 0 4px; }
|
||
.tile svg { display: block; flex: none; }
|
||
.tiles.rows { grid-template-columns: minmax(0, 1fr); gap: 7px; }
|
||
.tiles.rows .tile { flex-direction: row; justify-content: flex-start; text-align: left; min-height: 58px; padding: 8px 34px 8px 10px; gap: 11px; }
|
||
.tiles.rows .tile .tx { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||
.tiles.rows .tile .pr { flex: none; max-width: 118px; text-align: right; }
|
||
.tiles.rows .tile.on::after { top: 50%; margin-top: -8.5px; }
|
||
.tiles.rows .tile .tag { position: static; flex: none; }
|
||
/* steppers */
|
||
.stepper { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--stroke); border-radius: 999px; padding: 3px; background: var(--panel2); }
|
||
.stepper button { width: 30px; height: 30px; border-radius: 50%; border: 0; background: var(--panel); color: var(--fg); font-size: 17px; font-weight: 700; cursor: pointer; display: grid; place-items: center; line-height: 1; box-shadow: 0 1px 2px rgba(0,0,0,0.25); }
|
||
.stepper button:hover { background: var(--act); color: var(--onact); }
|
||
.stepper button:disabled { opacity: 0.35; cursor: not-allowed; background: var(--panel); color: var(--fg); }
|
||
.stepper input { width: 64px; text-align: center; border: 0; background: transparent; color: var(--fg); font: inherit; font-weight: 700; font-size: 17px; font-variant-numeric: tabular-nums; padding: 0; -moz-appearance: textfield; appearance: textfield; }
|
||
.stepper input::-webkit-outer-spin-button, .stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||
.stepper small { font-size: 11px; margin-right: 4px; }
|
||
.stepper.big { padding: 5px; } .stepper.big button { width: 40px; height: 40px; font-size: 21px; } .stepper.big b { min-width: 56px; text-align: center; font-size: 26px; }
|
||
/* swatches and effects */
|
||
.fx { border: 1px solid var(--stroke); border-radius: 10px; margin: 7px 0; background: var(--panel2); overflow: hidden; }
|
||
.fx.on { border-color: var(--act); box-shadow: 0 0 0 2px var(--actring); }
|
||
.fxh { display: flex; align-items: center; gap: 10px; width: 100%; background: transparent; border: 0; color: var(--fg); padding: 8px 11px; cursor: pointer; text-align: left; }
|
||
.fxh:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.fxh b { font-weight: 600; font-size: 14.5px; }
|
||
.fxh .sp { flex: 1; }
|
||
.fxh .pr { font-size: 12px; color: var(--accent); white-space: nowrap; }
|
||
.fxh .car { color: var(--mute); font-size: 12px; width: 12px; }
|
||
.pbadge { font-size: 11px; border: 1px solid var(--stroke); color: var(--mute); border-radius: 999px; padding: 0 7px; white-space: nowrap; }
|
||
.pbadge.dx { color: var(--info); border-color: var(--info); }
|
||
.fxb { padding: 2px 11px 10px; }
|
||
.slot { margin: 6px 0 2px; }
|
||
.slot > span { display: block; font-size: 12px; color: var(--mute); margin-bottom: 4px; }
|
||
.slot > span i { font-style: normal; color: var(--fg); }
|
||
.sws { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.sw { width: 24px; height: 24px; border-radius: 50%; border: 1px solid rgba(128,128,128,0.45); cursor: pointer; padding: 0; position: relative; flex: none; }
|
||
.sw.tr { background-image: repeating-linear-gradient(45deg, rgba(255,255,255,0.55) 0 2px, transparent 2px 5px) !important; }
|
||
.sw.none { background: transparent; border-style: dashed; }
|
||
.sw.none::before { content: ""; position: absolute; left: 3px; right: 3px; top: 50%; height: 1.5px; background: var(--mute); transform: rotate(-45deg); }
|
||
.sw.on { box-shadow: 0 0 0 2px var(--panel2), 0 0 0 4px var(--act); }
|
||
.sw:hover { transform: scale(1.12); }
|
||
.dtabs { display: flex; gap: 7px; flex-wrap: wrap; margin: 6px 0 4px; }
|
||
.dtab { display: flex; flex-direction: column; align-items: center; gap: 3px; background: var(--panel2); border: 1.5px solid var(--stroke); border-radius: 10px; padding: 6px 10px 5px; cursor: pointer; color: var(--fg); font-size: 12px; }
|
||
.dtab.on { border-color: var(--act); background: var(--actbg); box-shadow: 0 0 0 3px var(--actring); }
|
||
.try { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; margin: 0 0 6px; }
|
||
.try > span { font-size: 12px; color: var(--mute); }
|
||
.chip { display: inline-flex; align-items: center; gap: 6px; background: var(--panel2); border: 1px solid var(--stroke); color: var(--fg); border-radius: 999px; padding: 3px 10px 3px 4px; font-size: 12.5px; cursor: pointer; }
|
||
.chip:hover { border-color: var(--act); }
|
||
.meters { display: grid; grid-template-columns: auto 1fr; gap: 3px 8px; align-items: center; font-size: 10.5px; color: var(--mute); letter-spacing: 0.05em; margin-top: 4px; width: 100%; }
|
||
.mtr { display: flex; gap: 2px; } .mtr i { width: 12px; height: 7px; border-radius: 2px; background: var(--line); } .mtr i.f { background: var(--act); }
|
||
.tile.cs { align-items: stretch; text-align: left; justify-content: flex-start; }
|
||
/* forms */
|
||
input[type=text], input[type=email], input[type=tel], input[type=number], select, textarea { background: var(--input); color: var(--fg); border: 1px solid var(--stroke); border-radius: 7px; padding: 7px 9px; font: inherit; font-size: 14px; max-width: 100%; }
|
||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--act); box-shadow: 0 0 0 3px var(--actring); }
|
||
input:disabled, select:disabled, textarea:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
input.err, .fld.err input { border-color: var(--bad); background: var(--badbg); }
|
||
.fld { display: flex; flex-direction: column; gap: 3px; margin: 7px 0; font-size: 12.5px; color: var(--mute); }
|
||
.fld input, .fld select, .fld textarea { width: 100%; }
|
||
.fld.off { opacity: 0.5; }
|
||
.grid2f { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 10px; }
|
||
.chk { display: flex; gap: 8px; align-items: flex-start; font-size: 13.5px; margin: 8px 0; cursor: pointer; }
|
||
.chk input { margin-top: 3px; accent-color: var(--act); }
|
||
.chk.off { opacity: 0.45; cursor: not-allowed; }
|
||
fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 4px 12px 8px; margin: 10px 0; min-width: 0; }
|
||
fieldset legend { font-size: 12px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.07em; padding: 0 5px; }
|
||
fieldset:disabled { opacity: 0.5; }
|
||
.row { display: flex; justify-content: space-between; align-items: center; gap: 8px 12px; margin: 8px 0; flex-wrap: wrap; }
|
||
.row > .lbl { font-size: 13.5px; }
|
||
/* extras toggles */
|
||
.xopt .box { width: 20px; height: 20px; border-radius: 6px; border: 1.5px solid var(--stroke); display: grid; place-items: center; font-size: 13px; color: transparent; flex: none; }
|
||
.xopt.on .box { background: var(--act); border-color: var(--act); color: var(--onact); }
|
||
.tiles.rows .xopt.on::after { display: none; }
|
||
.tiles.rows .xopt { padding-right: 12px; }
|
||
/* notes, boxes */
|
||
.note { border-left: 3px solid var(--warn); padding: 7px 11px; background: var(--warnbg); border-radius: 6px; font-size: 13px; margin: 10px 0; }
|
||
.note.bad { border-left-color: var(--bad); background: var(--badbg); }
|
||
.note.ok { border-left-color: var(--accent); background: var(--okbg); }
|
||
.note.info { border-left-color: var(--info); background: var(--panel2); }
|
||
details.faq { border-bottom: 1px solid var(--line); padding: 7px 0; }
|
||
details.faq summary { cursor: pointer; font-size: 13.5px; }
|
||
details.faq p { font-size: 13px; color: var(--mute); margin: 6px 0 2px; }
|
||
.badge { display: inline-block; padding: 0 6px; border-radius: 4px; font-size: 11px; 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; flex: none; }
|
||
.st.ok { color: var(--accent); } .st.warn { color: var(--warn); } .st.bad { color: var(--bad); } .st.pend { color: var(--mute); }
|
||
ul.cl { list-style: none; padding: 0; margin: 4px 0; }
|
||
ul.cl li { display: flex; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||
ul.cl li:last-child { border-bottom: 0; }
|
||
ul.cl li small { display: block; }
|
||
.bar { height: 9px; background: var(--line); border-radius: 5px; overflow: hidden; position: relative; margin: 4px 0 2px; }
|
||
.bar > i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); border-radius: 5px; }
|
||
.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; }
|
||
.sidec { border: 1px solid var(--line); border-radius: 10px; padding: 8px 11px; margin: 8px 0; background: var(--panel2); }
|
||
.sidec .row { margin: 3px 0; }
|
||
.sidec .sn { font-weight: 700; font-size: 15px; }
|
||
.sidec .msg { font-size: 12px; margin: 3px 0 0; }
|
||
.sidec .msg.warn { color: var(--warn); } .sidec .msg.ok { color: var(--accent); } .sidec .msg.bad { color: var(--bad); }
|
||
/* tracks */
|
||
.tscroll { overflow-x: auto; }
|
||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||
th, td { padding: 5px 5px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
|
||
th { color: var(--mute); font-weight: 600; font-size: 11.5px; }
|
||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
#trk input.tt { width: 100%; min-width: 96px; padding: 4px 6px; font-size: 13px; }
|
||
#trk input.tdur { width: 52px; text-align: right; padding: 4px 5px; font-size: 13px; }
|
||
#trk select { padding: 3px 4px; font-size: 13px; }
|
||
#trk tr.dragging { opacity: 0.4; } #trk tr.dover td { border-top: 2px solid var(--act); }
|
||
#trk tr.sidehd td { color: var(--mute); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; padding-top: 9px; }
|
||
.drag { cursor: grab; color: var(--mute); user-select: none; width: 14px; text-align: center; }
|
||
.ib { background: transparent; border: 1px solid var(--stroke); color: var(--mute); border-radius: 5px; width: 22px; height: 22px; font-size: 10px; cursor: pointer; padding: 0; }
|
||
.ib:hover { color: var(--fg); border-color: var(--act); }
|
||
.ib:disabled { opacity: 0.3; cursor: not-allowed; }
|
||
.x { background: transparent; border: 0; color: var(--mute); font-size: 17px; cursor: pointer; padding: 0 3px; }
|
||
.x:hover { color: var(--bad); }
|
||
.drop { display: flex; align-items: center; gap: 9px; border: 1.5px dashed var(--stroke); border-radius: 10px; padding: 10px 12px; margin: 8px 0; color: var(--mute); font-size: 13px; cursor: pointer; }
|
||
.drop:hover, .drop.over { border-color: var(--act); color: var(--fg); background: var(--actbg); }
|
||
.drop b { color: var(--fg); font-weight: 600; }
|
||
.drop.off { opacity: 0.42; cursor: not-allowed; }
|
||
.drop.off:hover { border-color: var(--stroke); color: var(--mute); background: transparent; }
|
||
.drop .di { font-size: 20px; flex: none; }
|
||
.btn { background: var(--accent); color: var(--onacc); border: 1px solid var(--accent); border-radius: 8px; padding: 8px 14px; 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(--act); }
|
||
.btn.sm { padding: 4px 10px; font-size: 12.5px; }
|
||
.btn:disabled { opacity: 0.42; cursor: not-allowed; }
|
||
.btn.big { padding: 12px 18px; font-size: 16px; border-radius: 10px; }
|
||
.art { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
|
||
.art img { width: 70px; height: 70px; object-fit: cover; border-radius: 6px; border: 1px solid var(--stroke); flex: none; }
|
||
.art .aph { width: 70px; height: 70px; border-radius: 6px; border: 1px solid var(--stroke); flex: none; display: grid; place-items: center; font-weight: 700; color: var(--mute); }
|
||
.art ul { list-style: none; padding: 0; margin: 4px 0; font-size: 12.5px; }
|
||
.tpl { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin: 4px 0 8px; }
|
||
.tpl > div { display: flex; flex-direction: column; gap: 2px; }
|
||
.cnt { font-size: 11.5px; color: var(--mute); text-align: right; }
|
||
.mono { font-family: ui-monospace, Consolas, monospace; letter-spacing: 0.04em; }
|
||
/* stage */
|
||
.stage { position: relative; overflow: hidden; background: radial-gradient(120% 90% at 62% 40%, var(--st1), var(--st2)); }
|
||
#rec { position: absolute; inset: 0; }
|
||
#rec svg { display: block; position: absolute; left: 0; top: 0; }
|
||
.spin { transform-box: fill-box; transform-origin: center; animation: spin var(--dur, 1.8s) linear infinite; animation-play-state: paused; }
|
||
#rec:hover .spin, #rec.play .spin { animation-play-state: running; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||
.stx { fill: var(--stx); } .stl { stroke: var(--stx); }
|
||
.sinfo { position: absolute; left: 14px; top: 12px; display: flex; gap: 6px; flex-wrap: wrap; max-width: calc(100% - 290px); z-index: 2; pointer-events: none; }
|
||
.sinfo span { background: var(--glass); border: 1px solid var(--stroke); border-radius: 999px; padding: 2px 9px; font-size: 12px; color: var(--fg); white-space: nowrap; }
|
||
.sinfo span.dx { color: var(--info); border-color: var(--info); }
|
||
.sctl { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 8px; padding: 8px 12px; z-index: 2; flex-wrap: wrap; }
|
||
.sctl .seg { background: var(--glass); }
|
||
.sctl .cap { font-size: 12px; color: var(--mute); margin-left: auto; background: var(--glass); border-radius: 6px; padding: 2px 8px; }
|
||
.sdt { display: flex; gap: 4px; background: var(--glass); border: 1px solid var(--stroke); border-radius: 7px; padding: 2px; }
|
||
.sdt button { background: transparent; border: 0; color: var(--mute); padding: 3px 8px; border-radius: 5px; cursor: pointer; font-size: 12.5px; }
|
||
.sdt button.on { background: var(--act); color: var(--onact); }
|
||
.reopen { position: absolute; left: 12px; top: 46px; z-index: 3; }
|
||
/* quote card */
|
||
.qcard { position: absolute; top: 12px; right: 12px; width: 232px; background: var(--glass); border: 1px solid var(--stroke); border-radius: 14px; padding: 12px 14px 12px; z-index: 4; box-shadow: 0 8px 30px rgba(0,0,0,0.25); backdrop-filter: blur(6px); }
|
||
.qcard .qa { display: flex; gap: 6px; margin-right: 16px; }
|
||
.qcard .qa .btn { flex: 1; padding: 6px 6px; font-size: 12.5px; white-space: nowrap; }
|
||
.qsave { background: var(--act) !important; border-color: var(--act) !important; color: var(--onact) !important; }
|
||
.ql { margin-top: 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--warn); text-align: center; }
|
||
.ql.g { color: var(--accent); }
|
||
.qt { font-size: 25px; font-weight: 800; text-align: center; font-variant-numeric: tabular-nums; line-height: 1.15; }
|
||
.qs { font-size: 12px; color: var(--mute); text-align: center; }
|
||
.qd { font-size: 14px; font-weight: 600; text-align: center; }
|
||
.qcard hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0 7px; }
|
||
.qq { display: flex; justify-content: center; }
|
||
.qn { font-size: 11.5px; color: var(--warn); text-align: center; margin-top: 6px; }
|
||
.qmin { position: absolute; top: 6px; right: 7px; background: transparent; border: 0; color: var(--mute); cursor: pointer; font-size: 14px; padding: 0 4px; }
|
||
.qcard.min .qhide { display: none; }
|
||
.qcard.min { width: 200px; }
|
||
.qsv { font-size: 11.5px; color: var(--mute); text-align: center; margin-top: 6px; }
|
||
/* review */
|
||
.rv { max-width: 1060px; margin: 0 auto; }
|
||
.missbox { background: var(--warnbg); border: 1px solid var(--warn); border-radius: 12px; padding: 12px 16px; margin: 4px 0 14px; }
|
||
.missbox h3 { margin: 0 0 6px; font-size: 15.5px; display: flex; gap: 8px; align-items: center; }
|
||
.missbox ul { margin: 0; padding-left: 20px; font-size: 13.5px; }
|
||
.missbox li { margin: 3px 0; }
|
||
.missbox.ok { background: var(--okbg); border-color: var(--accent); }
|
||
.softbox { border: 1px dashed var(--stroke); border-radius: 12px; padding: 10px 16px; margin: 0 0 14px; font-size: 13px; }
|
||
.softbox ul { margin: 4px 0 0; padding-left: 20px; color: var(--mute); }
|
||
.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; }
|
||
tr.hd th { padding-top: 12px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
tr.sub td { color: var(--mute); }
|
||
tr.tot td { font-weight: 700; border-top: 1px solid var(--stroke); font-size: 14.5px; }
|
||
.kv { display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; font-size: 13.5px; margin: 8px 0; }
|
||
.kv > span { color: var(--mute); }
|
||
.kv > b { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.rvbar { flex: none; display: flex; gap: 10px; align-items: center; padding: 10px 18px; border-top: 1px solid var(--stroke); background: var(--panel); flex-wrap: wrap; }
|
||
.rvbar .place { flex: 1; min-width: 180px; }
|
||
.pass { color: var(--info); font-size: 11.5px; white-space: nowrap; }
|
||
.qrm { display: none; }
|
||
.conf { border: 1.5px solid var(--accent); border-radius: 14px; padding: 16px 18px; background: var(--okbg); margin-bottom: 14px; }
|
||
.conf h3 { margin: 0 0 4px; font-size: 18px; }
|
||
.conf ol { padding-left: 20px; font-size: 13.5px; } .conf li { margin: 5px 0; }
|
||
.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 */
|
||
@media (max-width: 1240px) and (min-width: 900px) {
|
||
.app, .app.closed, .app.wide { grid-template-columns: 64px 410px minmax(0, 1fr); }
|
||
.app.closed, .app.wide { grid-template-columns: 64px minmax(0, 1fr); }
|
||
.ri .rt, .ri .rch { display: none; }
|
||
.ri { justify-content: center; flex-direction: column; gap: 3px; padding: 6px 2px; min-height: 50px; }
|
||
.ri .rn { width: 20px; height: 20px; font-size: 11px; }
|
||
.rail { padding: 8px 6px; }
|
||
.qcard { width: 206px; padding: 10px 12px; }
|
||
.qt { font-size: 21px; }
|
||
.sinfo { max-width: calc(100% - 240px); }
|
||
}
|
||
@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; }
|
||
html, body { height: auto; }
|
||
header.top { height: auto; min-height: 50px; flex-wrap: wrap; padding: 6px 10px; gap: 6px 10px; }
|
||
.brand em { display: none; }
|
||
.app, .app.closed, .app.wide { display: flex; flex-direction: column; }
|
||
.rail { order: 0; } .stage { order: 1; } .panel { order: 2; }
|
||
.app.closed .panel { display: flex; }
|
||
.app.wide .stage { display: none; }
|
||
.rail { position: sticky; top: 0; z-index: 20; flex-direction: row; overflow-x: auto; overflow-y: hidden; padding: 6px 8px; gap: 4px; border-right: 0; border-bottom: 1px solid var(--stroke); justify-content: flex-start; scrollbar-width: none; }
|
||
.rail::-webkit-scrollbar { display: none; }
|
||
.rail .ri:first-child { margin-left: auto; } .rail .ri:last-child { margin-right: auto; }
|
||
.ri { width: auto; flex: none; min-height: 34px; padding: 4px 9px 4px 5px; gap: 6px; }
|
||
.ri .ric, .ri .rch, .ri .rt small { display: none; }
|
||
.ri .rt b { font-size: 12.5px; white-space: nowrap; }
|
||
.ri .rn { width: 21px; height: 21px; font-size: 11px; }
|
||
.stage { height: 342px; }
|
||
.sinfo { max-width: calc(100% - 20px); left: 8px; top: 8px; }
|
||
.sinfo span { font-size: 11px; }
|
||
.sctl { padding: 6px 8px; } .sctl .cap { display: none; }
|
||
.qcard, .reopen, .px { display: none !important; }
|
||
.panel { border-right: 0; }
|
||
.pb { overflow: visible; padding: 10px 14px 14px; }
|
||
.ph { margin: 10px 14px 0; }
|
||
.pf { padding: 10px 14px; }
|
||
.rvbar { display: none; }
|
||
.rvgrid { grid-template-columns: minmax(0, 1fr); }
|
||
#rvTab .qr { display: none; } #rvTab .qrm { display: block; } #rvTab td[colspan="3"] { padding-right: 0; }
|
||
.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; }
|
||
.pbar .pt small { font-size: 11px; white-space: nowrap; }
|
||
.pbar .stepper button { width: 26px; height: 26px; font-size: 15px; }
|
||
.pbar .stepper input { width: 46px; font-size: 14px; }
|
||
.pbar .btn { padding: 8px 11px; font-size: 13.5px; white-space: nowrap; }
|
||
}
|
||
@media (max-width: 420px) { .tiles { --cols: 2; } .tiles.c4 { --cols: 2; } .seg button { padding: 6px 9px; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="top">
|
||
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true"><circle cx="13" cy="13" r="12" fill="#111" stroke="var(--act)" stroke-width="1.5"/><circle cx="13" cy="13" r="8.5" fill="none" stroke="rgba(255,255,255,0.12)"/><circle cx="13" cy="13" r="6" fill="none" stroke="rgba(255,255,255,0.12)"/><circle cx="13" cy="13" r="4" fill="var(--act)"/><circle cx="13" cy="13" r="1" fill="#111"/></svg><b>CNX Vinyl</b><span>Order</span><em>configure a record, get an itemised quote (demo)</em></div>
|
||
<div class="prefs">
|
||
<div class="seg cmp" data-k="cur" title="Currency"><button type="button" data-v="THB">THB</button><button type="button" data-v="USD">USD</button><button type="button" data-v="EUR">EUR</button></div>
|
||
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
|
||
</div>
|
||
</header>
|
||
<div class="app" id="app">
|
||
<nav class="rail" id="rail" aria-label="Order steps"></nav>
|
||
<section class="panel" id="panel">
|
||
<div class="ph"><span class="pn" id="pNum">1</span><span class="pic" id="pIcon"></span><h2 id="pTitle">Order type</h2><button type="button" class="px" data-act="close" title="Close the panel and look at the record">×</button></div>
|
||
<div class="pb" id="pBody"></div>
|
||
<div class="pf" id="pFoot">
|
||
<button type="button" class="nextb" data-act="next" id="nextB">Next step →</button>
|
||
<div class="row2"><span class="foot">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</span><button type="button" class="linkb" data-act="go" data-s="16" id="priceLink">Just show me the price →</button></div>
|
||
</div>
|
||
<div class="rvbar" id="rvBar" hidden>
|
||
<button type="button" class="btn sec" data-act="share">⤴ Share quote</button>
|
||
<div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">−</button><input type="number" class="qtyIn" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div>
|
||
<button type="button" class="btn big place" data-act="place" id="placeB">Place order →</button>
|
||
</div>
|
||
</section>
|
||
<section class="stage" id="stage">
|
||
<div id="rec" title="Hover to spin"></div>
|
||
<div class="sinfo" id="sinfo"></div>
|
||
<button type="button" class="btn sec sm reopen" data-act="reopen" id="reopenB" hidden>‹ Show the step panel</button>
|
||
<aside class="qcard" id="qcard">
|
||
<button type="button" class="qmin" data-act="qmin" title="Make the card smaller">−</button>
|
||
<div class="qa qhide"><button type="button" class="btn qsave" data-act="save" title="Save this quote in this browser">Save quote</button><button type="button" class="btn sec" data-act="share" title="Copy a link that opens this configuration">⤴ Share</button></div>
|
||
<div class="ql" id="qL1">Estimated price</div>
|
||
<div class="qt" id="qTot"></div>
|
||
<div class="qs" id="qPer"></div>
|
||
<div class="qhide"><div class="ql g" id="qL2">Estimated ship date</div>
|
||
<div class="qd" id="qShip"></div>
|
||
<div class="qs" id="qShipN"></div></div>
|
||
<div class="qhide" id="qKeepW" hidden><div id="qKeep"></div></div>
|
||
<hr>
|
||
<div class="qq"><div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">−</button><input type="number" class="qtyIn" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div></div>
|
||
<div class="qn" id="qNote"></div>
|
||
<div class="qsv qhide" id="qSaved"></div>
|
||
</aside>
|
||
<div class="sctl" id="sctl">
|
||
<div class="seg cmp" data-k="view" title="What the picture shows"><button type="button" data-v="record">Record</button><button type="button" data-v="jacket">Jacket</button></div>
|
||
<div class="sdt" id="sDiscs"></div>
|
||
<span class="cap" id="sCap"></span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div class="pbar" id="pbar">
|
||
<div class="pt"><b id="pbTot"></b><small id="pbPer"></small></div>
|
||
<div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">−</button><input type="number" class="qtyIn" min="25" max="5000" aria-label="Copies"><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div>
|
||
<button type="button" class="btn" id="pbBtn" data-act="pbgo">Price →</button>
|
||
</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, THB_EUR=THB_USD*USD_EUR, PLV="demo-0.1"; /* 1 EUR = 1.155 USD = 38.115 THB, as in make-a-record */
|
||
const RATE={THB:1,USD:THB_USD,EUR:THB_EUR};
|
||
|
||
/* ---- price list: every rate with its basis (doc = planning docs, team = team figure, est = estimate).
|
||
Copied from artist-order.html (same values); lines new on this page are marked "new on this page" in the source note. ---- */
|
||
const R={
|
||
master:{v:5000,u:"per side",t:"Vinyl mastering in-house",k:"est",src:"Estimate. For scale: audio mastering in the Thai market runs 5,000 to 20,000 THB per song (revenue model)."},
|
||
masterTrack:{v:1980,u:"per track",t:"Mastering by a partner studio, per track",k:"team",src:"60 USD a track (team figure, October 2026) at 1 USD = 33 THB. Brandenburg Mastering, Wibautstraat 150, Amsterdam (brandenburgmastering.com; it quotes on request, the site publishes no price list). Example partner, not yet contracted. New on this page; replaces the in-house line, it does not stack."},
|
||
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)."},
|
||
stamper:{v:2500,u:"per side",t:"Repress: new stampers grown from the archived mother",k:"est",src:"Estimate, new on this page. One of the three electroformed generations in the 7,000 THB line above, plus pulling the mother from the archive; the plant keeps the father and the mother of every side (production timeline), so a repress needs no master, lacquer or cut."},
|
||
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."},
|
||
colourFx:{v:{trans:20,split:40,cic:45},u:"per disc",t:"Translucent, split and colour-in-colour vinyl",k:"est",src:"Estimate, new on this page, inside the same 20 to 56 THB band (revenue model): translucent is one compound like a solid colour; split and colour-in-colour need both extruders of the Duplex system, priced between marbled and splatter, and at splatter."},
|
||
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. Black sleeves are priced as white (estimate)."},
|
||
innerPrint:{v:{12:19,10:16,7:10},u:"per disc",t:"Printed inner sleeve instead of a plain one",k:"est",src:"Estimate, new on this page: the paper sleeve it replaces (7 THB on a 12\") plus about the printed insert rate (12 THB), scaled by area for 10\" and 7\", until the packaging partner quotes."},
|
||
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."},
|
||
spine:{v:{12:12,10:10},u:"per copy",t:"Thick 5 mm spine instead of 3 mm (two records, or a thick insert)",k:"est",src:"Estimate. Quality Record Pressings (US) lists a generic wide-spine jacket at 0.90 USD against 0.55 for a standard one, about 12 THB more; Kudos (UK) charges 137.50 GBP per order for an extra wide spine (price lists, checked October 2026)."},
|
||
finish:{v:{matt:{12:8,10:7,7:4,gf:14},gloss:{12:8,10:7,7:4,gf:14},spotuv:{12:15,10:13,7:8,gf:26}},u:"per jacket",t:"Jacket finish: lamination or spot UV",k:"est",src:"Estimate, new on this page, until the packaging partner quotes: one lamination pass on the printed jacket sheet, about twice the sheet for a gatefold; spot UV adds a screen and a varnish pass. Uncoated costs nothing extra."},
|
||
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."},
|
||
obi:{v:10,u:"per copy",t:"Obi strip",k:"est",src:"Estimate, new on this page: a printed paper band folded round the jacket and fitted by hand, priced between the hype sticker (6) and the printed insert (12)."},
|
||
bag:{v:6,u:"per copy",t:"Outer bag, PVC, resealable",k:"est",src:"Estimate, new on this page: a resealable outer sleeve fitted at packing, priced like the shrink wrap plus the fitting."},
|
||
tape:{v:3800,u:"per title",t:"Analog tape transfer",k:"est",src:"Estimate. Record Industry (NL) charges 100 EUR more for cutting from tape than from a digital master (online calculator, checked October 2026)."},
|
||
clean:{v:2300,u:"per title",t:"Press cleaned before a colour run",k:"est",src:"Estimate. Record Industry charges 60 EUR for press cleaning and warns that without it records can carry the previous colour (online calculator, October 2026)."},
|
||
plainLabel:{v:-12,u:"per disc",t:"Plain labels instead of printed (credit)",k:"est",src:"Estimate. Record Industry prices white unprinted labels about 0.32 EUR a record below CMYK printed at 300 copies (online calculator, October 2026)."},
|
||
dlcard:{v:43,u:"per copy",t:"Download card, printed both sides",k:"est",src:"Estimate. Record Industry: 361 EUR for a 4/4 card at 300 copies, about 1.13 EUR a copy (online calculator, October 2026)."},
|
||
barcode:{v:6,u:"per copy",t:"Barcode sticker on the back",k:"est",src:"Estimate. Record Industry: 0.15 EUR a copy (online calculator, October 2026)."},
|
||
shipTH:{v:220,u:"per carton",t:"Delivery in Thailand",k:"est",src:"Estimate, new on this page: a domestic courier rate for one carton of about 8 kg, until carriers quote."},
|
||
shipIntl:{v:380,u:"per kg",t:"International delivery, air parcel",k:"est",src:"Estimate, new on this page: an air parcel rate per kg; duties and taxes are paid by the receiver. Sea freight for large runs is quoted on request."},
|
||
wCopy:{v:{12:0.30,10:0.22,7:0.07,x12:0.16,x10:0.12,x7:0.05,g180:0.04},u:"kg per copy",t:"Shipping weight per copy",k:"est",src:"Estimate, new on this page: a 12\" LP with its jacket weighs about 0.3 kg; each further disc in its sleeve adds about 0.16 kg (12\"), 180 g vinyl 0.04 kg a disc."},
|
||
carton:{v:{12:25,10:30,7:50},u:"copies per carton",t:"Copies per shipping carton",k:"est",src:"Estimate, new on this page: one mailer carton of about 8 kg."},
|
||
minRun:{v:100,u:"copies",t:"Minimum run for an order",k:"doc",src:"Planning figure, to be decided (artist platform, open decision 4). Smaller runs are quoted, at a higher price per copy."},
|
||
deposit:{v:0.5,u:"of a direct order",t:"Deposit on a direct order",k:"est",src:"Placeholder; deposit terms are not set yet."},
|
||
fee:{v:0.035,u:"of the fan price",t:"Payment fees on fan pre-orders",k:"doc",src:"About 3 to 4 % on cards, less on PromptPay, to be confirmed with the provider (artist platform). Same rate as artist-order and make-a-record."},
|
||
vatPct:{v:7,u:"% inside a fan price",t:"Thai VAT in a fan price",k:"doc",src:"With CNX as seller of record (artist platform, open decision 1) a fan price includes 7/107 of VAT, which comes out of the artist's margin; the switch on the fan funding step shows the numbers with it taken out, as make-a-record does. New on this page as a list entry."},
|
||
campDays:{v:{min:14,max:45,def:30},u:"days",t:"Campaign length the artist chooses",k:"doc",src:"14 to 45 days, an estimate (artist platform, The campaign page); 30 days is the demo default, as in make-a-record. New on this page as a list entry."}
|
||
};
|
||
const tierIdx=q=>q>=1000?3:q>=500?2:q>=300?1:0;
|
||
/* side length per format, minutes [comfortable, at most]: comfortable = lower end of the microcut.studio format guide;
|
||
at most = the team's table (October 2026, from recordplayerexpert.com), see artist-platform.md "Side length per format" */
|
||
const LIM={"12":{"33":[18,22],"45":[12,15]},"10":{"33":[12,15],"45":[8,12]},"7":{"33":[5,7],"45":[4.5,5]}};
|
||
const SONG=272; /* seconds: the 4:32 average song the at-most table counts songs with */
|
||
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"},label:{t:"Labels"},inner:{t:"Inner sleeve"}};
|
||
/* 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]}
|
||
];
|
||
/* repress from an archived mother: production timeline "Repress: the shortest path in the plant", quoted 2 to 3 weeks */
|
||
const RSTG=[
|
||
{id:"pull",t:"Pull a stamper from the archived mother",d:[1,2]},
|
||
{id:"prep",t:"Stamper prep",d:[1,1]},
|
||
{id:"tp",t:"Press setup, first-article check",d:[0.5,0.5]},
|
||
{id:"run",t:"Production run (1,000)",d:[1,3]},
|
||
{id:"fin",t:"Finishing, assembly, dispatch",d:[3,5]}
|
||
];
|
||
/* real sizes, mm: record diameter (IEC 60098: 301.6 / 250.8 / 174.6), jacket (12.375" / 10.25" / 7.25"), label */
|
||
const SIZE_MM={"12":{rec:302,jk:314,lab:100,name:"12\" LP"},"10":{rec:250,jk:260,lab:100,name:"10\""},"7":{rec:175,jk:184,lab:90,name:"7\" single"}};
|
||
|
||
/* vinyl colours: name, hex, translucent */
|
||
const COL={
|
||
black:["Black","#151515"],white:["White","#f2f0e9"],bone:["Bone","#e4d6b6"],grey:["Grey","#8b9099"],
|
||
red:["Red","#c62828"],oxblood:["Oxblood","#6b1b22"],orange:["Orange","#ee7c1b"],yellow:["Yellow","#f4c430"],
|
||
mustard:["Mustard","#c79d24"],green:["Green","#2e8b4f"],mint:["Mint","#9fe0c2"],blue:["Blue","#1f5fbf"],
|
||
sky:["Sky blue","#8fc3ec"],purple:["Purple","#6a3fa0"],pink:["Pink","#ef80b2"],
|
||
clear:["Clear","#dfe8ee",1],smoke:["Smoke","#3a3d44",1],tred:["Translucent red","#d32f2f",1],torange:["Translucent orange","#f28c28",1],
|
||
tyellow:["Translucent yellow","#f7d33e",1],tgreen:["Translucent green","#35a96b",1],tblue:["Translucent blue","#2b6de0",1],
|
||
tpurple:["Translucent purple","#7c4cc8",1],tpink:["Translucent pink","#f087bd",1]
|
||
};
|
||
const SOLID=Object.keys(COL).filter(k=>!COL[k][2]),TRANS=Object.keys(COL).filter(k=>COL[k][2]);
|
||
const PAL={solid:SOLID,trans:TRANS,all:SOLID.concat(TRANS),solidN:SOLID.concat(["none"])};
|
||
const FX={
|
||
solid:{t:"Solid colour",slots:[["Colour","solid"]],dx:false},
|
||
trans:{t:"Translucent",slots:[["Colour","trans"]],dx:false},
|
||
split:{t:"Split",slots:[["Left half","all"],["Right half","all"]],dx:true},
|
||
splatter:{t:"Splatter",slots:[["Base","all"],["Splatter 1","solid"],["Splatter 2","solidN"],["Splatter 3","solidN"]],dx:true},
|
||
marbled:{t:"Marble",slots:[["Base","all"],["Swirl","solid"]],dx:true},
|
||
cic:{t:"Colour-in-colour",slots:[["Centre","all"],["Outer","all"]],dx:true},
|
||
glitter:{t:"Glitter",slots:[],dx:true,off:"Not offered at launch"}
|
||
};
|
||
const FXO=["solid","trans","split","splatter","marbled","cic","glitter"];
|
||
const newDisc=()=>({fx:"solid",c:{solid:["black"],trans:["clear"],split:["red","yellow"],splatter:["clear","red","yellow","none"],marbled:["blue","white"],cic:["yellow","red"]}});
|
||
const GENRES=[["pop","Pop, rock, indie","std"],["jazz","Jazz, classical, acoustic","dyn"],["folk","Folk, Thai traditional, luk thung","std"],["hiphop","Hip-hop, R&B","bass"],["dance","Electronic, dance, techno","bass"],["dub","Reggae, dub","bass"],["spoken","Spoken word","speech"]];
|
||
|
||
const TODAY=new Date(); TODAY.setHours(12,0,0,0);
|
||
const blankAddr=()=>({name:"",company:"",email:"",phone:"",line1:"",city:"",postcode:"",country:""});
|
||
const S={
|
||
step:1,cur:"THB",otype:"new",size:"12",rpm:"33",discs:1,weight:140,dl:[newDisc(),newDisc(),newDisc(),newDisc()],vd:0,
|
||
qty:300,labels:"printed",inner:"paper",innerCol:"white",jacket:"printed",spine:"thin",finish:"uncoated",
|
||
shrink:true,insert:false,sticker:false,numbered:false,dlcard:false,barcode:false,obi:false,bag:false,
|
||
master:"inhouse",tps:5,cutStyle:"balanced",loud:false,source:"digital",cleaning:true,dinked:false,catno:"DEMO001",
|
||
artist:"Demo Artist",title:"Night Market Songs",tracks:[],demoTracks:true,art:{jacket:null,label:null,inner:null},
|
||
srpm:{},stype:{},gaps:"files",genre:"pop",leadout:"",
|
||
ship:"pickup",split:false,splitQty:50,ship2:"th",duty:"dap",same:true,
|
||
addr:{b:Object.assign(blankAddr(),{country:"Thailand"}),s:Object.assign(blankAddr(),{country:"Thailand"}),x:Object.assign(blankAddr(),{country:"Thailand"})},
|
||
notes:"",view:"record",viewMan:false,closed:false,qmin:false,placed:null,visited:{1:true},
|
||
/* how to pay: direct order or fan campaign; the campaign fields and defaults are make-a-record's */
|
||
route:"direct",fanPrice:1200,days:R.campDays.v.def,limited:false,capN:300,artistCopies:0,vat:false,
|
||
launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0
|
||
};
|
||
let tid=0;
|
||
[["Doi Suthep at Dawn",262],["Ping River",241],["Nimman Rain",198],["Songthaew",225],["Old City Walls",284],["Lanna Lights",233],["Mae Rim Drive",256],["Last Train North",301]]
|
||
.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");
|
||
const rate=()=>RATE[S.cur]||1;
|
||
function money(thb,o){o=o||{};
|
||
if(S.cur!=="THB"){const u=thb/rate(),d=o.dec!=null?o.dec:(Math.abs(u)<1000?2:0);return u.toLocaleString("en-US",{minimumFractionDigits:d,maximumFractionDigits:d})+(o.bare?"":" "+S.cur);}
|
||
return Math.round(thb).toLocaleString("en-US")+(o.bare?"":" THB");}
|
||
const plus=v=>(v>0?"+":v<0?"-":"")+money(Math.abs(v));
|
||
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 fmtDs=d=>d.toLocaleDateString("en-GB",{day:"numeric",month:"short"});
|
||
function dRange(a,b){return a.getFullYear()===b.getFullYear()?fmtDs(a)+" to "+fmtD(b):fmtD(a)+" to "+fmtD(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;}
|
||
function toast(t){const el=document.createElement("div");el.className="toast";el.textContent=t;document.body.appendChild(el);setTimeout(()=>el.remove(),2600);}
|
||
|
||
/* ---- state helpers ---- */
|
||
const rep=()=>S.otype==="repress", lq=()=>S.source==="lacquer";
|
||
const mastOn=()=>S.master!=="none"&&!lq()&&!rep();
|
||
const dinkOn=()=>S.dinked&&S.size==="7";
|
||
const isBlack=d=>d.fx==="solid"&&d.c.solid[0]==="black";
|
||
const discsOn=()=>S.dl.slice(0,S.discs);
|
||
const colourOn=()=>discsOn().some(d=>!isBlack(d));
|
||
const dxOn=()=>discsOn().some(d=>FX[d.fx].dx);
|
||
const cleanOn=()=>S.cleaning&&colourOn();
|
||
const finishOn=()=>S.jacket==="printed"||S.jacket==="gatefold";
|
||
const sideNames=()=>"ABCDEFGH".slice(0,2*S.discs).split("");
|
||
const stypeOf=n=>S.stype[n]||"audio";
|
||
const audioSides=()=>sideNames().filter(n=>stypeOf(n)==="audio");
|
||
const rpmOf=n=>S.srpm[n]||S.rpm;
|
||
const rpmT=r=>r==="45"?"45 rpm":"33⅓ rpm";
|
||
const audioTracks=()=>{const a=audioSides();return S.tracks.filter(t=>a.includes(t.side));};
|
||
/* a stage runs unless the order skips it: no mastering with a final master or own lacquers, no cutting with own lacquers */
|
||
const stageOn=s=>s.opt==="mastering"?mastOn():s.id==="cut"?!lq():true;
|
||
const cname=k=>k==="none"?"none":COL[k]?COL[k][0]:k;
|
||
function fxName(d){const c=d.c[d.fx]||[];
|
||
switch(d.fx){case "solid":return cname(c[0]);case "trans":return cname(c[0]);
|
||
case "split":return "Split, "+cname(c[0]).toLowerCase()+" and "+cname(c[1]).toLowerCase();
|
||
case "splatter":{const sp=c.slice(1).filter(x=>x!=="none").map(x=>cname(x).toLowerCase());return "Splatter, "+sp.join(", ")+" on "+cname(c[0]).toLowerCase();}
|
||
case "marbled":return "Marble, "+cname(c[0]).toLowerCase()+" and "+cname(c[1]).toLowerCase();
|
||
case "cic":return cname(c[0])+" in "+cname(c[1]).toLowerCase();}
|
||
return FX[d.fx].t;}
|
||
function fxRate(d){if(isBlack(d))return 0;if(d.fx==="solid")return R.colour.v.solid;if(d.fx==="marbled"||d.fx==="splatter")return R.colour.v[d.fx];return R.colourFx.v[d.fx]||0;}
|
||
function colSum(){const ds=discsOn();if(ds.length>1&&ds.some(d=>fxName(d)!==fxName(ds[0])))return ds.length+" discs, mixed";return fxName(ds[0]);}
|
||
function descT(){return (S.discs>1?S.discs+" × ":"")+S.size+"\""+(dinkOn()?" dinked":"")+", "+rpmT(S.rpm)+", "+(S.size==="12"?S.weight+" g ":"")+colSum().toLowerCase()+", "+JNAME[S.jacket].toLowerCase();}
|
||
|
||
/* ---- pricing: quote() as in artist-order.html, extended for per-disc colours, blank sides, mastering choice, repress, finish, extras and shipping ---- */
|
||
function quote(q){
|
||
const sz=S.size,d=S.discs,sides=2*d,T=[],C=[],H=[],du=d>1?"discs":"disc",as=audioSides().length;
|
||
if(!rep()){
|
||
if(mastOn()&&as){
|
||
if(S.master==="partner"){const n=audioTracks().length;if(n)T.push({id:"masterTrack",t:"Mastering by a partner studio (Brandenburg Mastering, Amsterdam), "+n+" tracks × 60 USD",n,u:"tracks",r:R.masterTrack.v});}
|
||
else T.push({id:"master",t:"Vinyl mastering in-house",n:as,u:"sides",r:R.master.v});
|
||
}
|
||
if(S.source==="tape")T.push({id:"tape",t:"Analog tape transfer",n:1,u:"",r:R.tape.v});
|
||
if(!lq()&&as){T.push({id:"blank",t:"Lacquer blanks, 70 EUR each",n:as,u:"sides",r:R.blank.v});T.push({id:"cut",t:"Lacquer cutting",n:as,u:"sides",r:R.cut.v});}
|
||
T.push({id:"metal",t:"Electroforming: father, mother (kept for represses), stamper",n:sides,u:"sides",r:R.metal.v});
|
||
}else T.push({id:"stamper",t:"New stampers from the archived mother",n:sides,u:"sides",r:R.stamper.v});
|
||
T.push({id:"setup",t:"Press setup",n:d,u:du,r:R.setup.v});
|
||
if(S.tps>0)T.push({id:"tp",t:"Test pressings for you",n:S.tps,u:"copies",r:R.tp.v});
|
||
if(cleanOn())T.push({id:"clean",t:"Press cleaned before your colour",n:1,u:"",r:R.clean.v});
|
||
C.push({id:"press",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({id:"g180",t:"180 g instead of 140 g",n:d,u:du,r:R.g180.v});
|
||
const grp={};discsOn().forEach(x=>{const r=fxRate(x);if(!r)return;const key=x.fx+"|"+fxName(x);(grp[key]=grp[key]||{t:"Colour vinyl, "+fxName(x).toLowerCase(),n:0,r,id:FX[x.fx]&&(x.fx==="trans"||x.fx==="split"||x.fx==="cic")?"colourFx":"colour"}).n++;});
|
||
Object.values(grp).forEach(g=>C.push({id:g.id,t:g.t,n:g.n,u:g.n>1?"discs":"disc",r:g.r}));
|
||
if(S.inner==="printed")C.push({id:"innerPrint",t:"Inner sleeve, printed",n:d,u:du,r:R.innerPrint.v[sz]});
|
||
else C.push({id:"inner",t:"Inner sleeve, "+(S.inner==="poly"?"poly-lined":"paper")+", "+S.innerCol,n:d,u:du,r:R.inner.v[S.inner][sz]});
|
||
C.push({id:"assy",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({id:"jacket",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(thickOn())C.push({id:"spine",t:"Thick 5 mm spine",n:1,u:"",r:R.spine.v[sz]});
|
||
if(finishOn()&&S.finish!=="uncoated")C.push({id:"finish",t:"Jacket finish, "+FINN[S.finish],n:1,u:"",r:R.finish.v[S.finish][S.jacket==="gatefold"?"gf":sz]});
|
||
if(S.labels!=="printed")C.push({id:"plainLabel",t:"Plain "+S.labels+" labels instead of printed",n:d,u:du,r:R.plainLabel.v});
|
||
if(S.shrink)C.push({id:"shrink",t:"Shrink wrap",n:1,u:"",r:R.shrink.v});
|
||
if(S.dlcard)C.push({id:"dlcard",t:"Download card",n:1,u:"",r:R.dlcard.v});
|
||
if(S.barcode)C.push({id:"barcode",t:"Barcode sticker",n:1,u:"",r:R.barcode.v});
|
||
if(S.insert)C.push({id:"insert",t:"Printed insert",n:1,u:"",r:R.insert.v});
|
||
if(S.sticker)C.push({id:"sticker",t:"Hype sticker",n:1,u:"",r:R.sticker.v});
|
||
if(S.numbered)C.push({id:"numbered",t:"Hand-numbered",n:1,u:"",r:R.numbered.v});
|
||
if(S.obi)C.push({id:"obi",t:"Obi strip",n:1,u:"",r:R.obi.v});
|
||
if(S.bag)C.push({id:"bag",t:"Outer bag, PVC, resealable",n:1,u:"",r:R.bag.v});
|
||
shipLines(q).forEach(x=>H.push(x));
|
||
T.forEach(x=>x.a=x.n*x.r);C.forEach(x=>x.a=x.n*x.r);H.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),hSum=H.reduce((s,x)=>s+x.a,0);
|
||
return {T,C,H,tSum,cSum,hSum,q,total:tSum+q*cSum+hSum,per:tSum/q+cSum};
|
||
}
|
||
const FINN={uncoated:"uncoated",matt:"matt lamination",gloss:"gloss lamination",spotuv:"spot UV"};
|
||
function wPer(){const sz=S.size,w=R.wCopy.v;return w[sz]+(S.discs-1)*w["x"+sz]+(sz==="12"&&S.weight===180?w.g180*S.discs:0);}
|
||
function shipLines(q){
|
||
const L=[],n2=S.split?Math.max(0,Math.min(S.splitQty,q-1)):0,legs=[[S.ship,q-n2,""]];
|
||
if(S.split&&n2>0)legs.push([S.ship2,n2,"Second address: "]);
|
||
const perCarton=Math.max(1,Math.floor(R.carton.v[S.size]*R.wCopy.v[S.size]/wPer()));
|
||
legs.forEach(([m,n,who])=>{if(n<=0)return;const kg=n*wPer(),ct=Math.ceil(n/perCarton);
|
||
if(m==="pickup")L.push({id:"pickup",t:who+"Pick-up in Chiang Mai, "+fmtInt(n)+" copies",n:1,u:"",r:0});
|
||
else if(m==="th")L.push({id:"shipTH",t:who+"Delivery in Thailand, "+fmtInt(n)+" copies, about "+fmtInt(kg)+" kg",n:ct,u:ct===1?"carton":"cartons",r:R.shipTH.v});
|
||
else L.push({id:"shipIntl",t:who+"International air parcel, "+fmtInt(n)+" copies",n:Math.ceil(kg),u:"kg",r:R.shipIntl.v});});
|
||
return L;
|
||
}
|
||
function leadInfo(campaign){
|
||
const printed=S.jacket==="printed"||S.jacket==="gatefold";
|
||
if(rep())return {pre:RSTG,pw:[0,0],qw:[2,3],tw:[2,3],printed,repress:true};
|
||
const g3=STG.findIndex(s=>s.id==="g3");
|
||
const pre=STG.slice(0,g3+1).filter(s=>stageOn(s)&&!(campaign&&(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 qw=printed?[7,9]:[5,8];
|
||
return {pre,pw,qw,tw:[pw[0]+qw[0],pw[1]+qw[1]],printed};
|
||
}
|
||
function shipWindow(){const L=leadInfo(false);return [addWD(TODAY,L.tw[0]*5),addWD(TODAY,L.tw[1]*5)];}
|
||
|
||
/* ---- fan funding: campEcon() as in make-a-record.html (same maths, same rule: fans needed = max(minimum run, break-even)) ---- */
|
||
const fansOn=()=>S.route==="fans";
|
||
const capOn=()=>S.limited?S.capN:0; /* the effective edition cap, 0 when the limited edition is off (make-a-record's S.cap) */
|
||
function campEcon(){
|
||
const p=S.fanPrice,fee=R.fee.v,vat=S.vat?p*R.vatPct.v/(100+R.vatPct.v):0,net1=p-p*fee-vat,tSum=quote(R.minRun.v).tSum,ac=S.artistCopies;
|
||
const cAt=n=>quote(Math.max(1,n)).cSum;
|
||
let be=null;
|
||
for(let n=1;n<=5000;n++){if(n*net1-tSum-(n+ac)*cAt(n+ac)>=0){be=n;break;}}
|
||
/* the artist sets the fan price; the fans needed follow: the plant minimum, or the break-even if that is higher */
|
||
const min=R.minRun.v,goal=be==null?null:Math.max(min,be);
|
||
const at=n=>{const run=n+ac,c=cAt(run),net=n*net1,cost=tSum+run*c;return {n,run,c,net,cost,keep:net-cost,per:(net-cost)/n};};
|
||
return {p,fee,vat,net1,tSum,be,min,goal,at,cAt};
|
||
}
|
||
/* one campEcon per state: render passes ask for it several times */
|
||
let ECK="",ECV=null;
|
||
function econ(){const k=JSON.stringify(cfg())+"|"+audioTracks().length;if(k!==ECK||!ECV){ECK=k;ECV=campEcon();}return ECV;}
|
||
/* the lowest fan price at which the plant minimum covers the costs; below it the break-even sets the fans needed (make-a-record pRaw) */
|
||
function priceHint(E){E=E||campEcon();const ac=S.artistCopies,keepShare=1-R.fee.v-(S.vat?R.vatPct.v/(100+R.vatPct.v):0),pRaw=(E.tSum+(E.min+ac)*E.cAt(E.min+ac))/E.min/keepShare;
|
||
const pMin=S.cur==="THB"?Math.ceil(pRaw/10)*10:Math.ceil(pRaw/rate())*rate();return {pRaw,pMin,t:money(pMin,{dec:0})};}
|
||
function keepRows(E){const cap=capOn();if(E.goal==null)return [];return [...new Set([E.goal,200,300,500,1000])].filter(n=>n>=E.goal&&(!cap||n<=cap)).sort((a,b)=>a-b).map(E.at);}
|
||
const ROUTEW="Choose \"Let fans fund it\" above to set up a campaign";
|
||
/* why a campaign cannot launch: the route, a price below the cost per copy, an edition cap below the goal */
|
||
function campWhy(E){E=E||econ();if(!fansOn())return ROUTEW;if(E.goal==null)return "Raise the fan price: it does not cover the cost per copy";const c=capOn();if(c&&c<E.goal)return "The edition cap of "+fmtInt(c)+" is below the goal of "+fmtInt(E.goal)+" fans";return "";}
|
||
/* the fan price is kept in THB; it is typed and shown in the chosen currency (make-a-record) */
|
||
const toTHB=v=>Math.round(v*rate()*100)/100;
|
||
const rngP=el=>{const a=+el.min||0,b=+el.max||100;el.style.setProperty("--p",(Math.max(0,Math.min(1,(+el.value-a)/((b-a)||1)))*100).toFixed(1)+"%");};
|
||
const fanIn=()=>S.cur==="THB"?Math.round(S.fanPrice):Math.round(S.fanPrice/rate()*100)/100;
|
||
const FAN_IN={THB:[300,5000,50],USD:[10,150,1],EUR:[8,130,1]},FAN_RNG={THB:[300,3000,50],USD:[10,90,1],EUR:[8,80,1]},FP={THB:[800,1000,1200,1500,2000],USD:[25,30,35,45,60],EUR:[20,25,30,40,50]};
|
||
/* campaign simulation (make-a-record): final pledges per scenario, share of them in by time t */
|
||
function finalPledges(E){
|
||
if(!E.goal)return 0;const g=E.goal;
|
||
let P=S.scen==="sell"?Math.ceil(g*1.6):S.scen==="make"?Math.ceil(g*1.08):Math.floor(g*0.68);
|
||
const c=capOn();if(c>0)P=Math.min(P,c);return P;
|
||
}
|
||
const frac=t=>0.38*(1-Math.exp(-t/0.07))/(1-Math.exp(-1/0.07))+0.37*t+0.25*Math.pow(t,6);
|
||
function pledgesNow(E){const P=finalPledges(E);return S.simDay>=S.days?P:Math.round(P*frac(S.simDay/S.days));}
|
||
function campWindow(){const close=addDays(S.launchDate||TODAY,S.days),L=leadInfo(true);return {close,L,ship:[addWD(close,L.tw[0]*5),addWD(close,L.tw[1]*5)]};}
|
||
|
||
/* ---- tracks and sides ---- */
|
||
function parseDur(s){s=String(s).trim();let m=s.match(/^(\d+):(\d{1,2})$/);if(m)return +m[1]*60+ +m[2];m=s.match(/^\d+(\.\d+)?$/);if(m)return Math.round(parseFloat(s)*60);return null;}
|
||
const gapSec=n=>S.gaps==="add"?Math.max(0,n-1)*2:0;
|
||
function sideStats(){
|
||
return sideNames().map(n=>{const rpm=rpmOf(n),L=LIM[S.size][rpm].map(m=>m*60*(S.loud?LOUD:1)),type=stypeOf(n);
|
||
const tr=S.tracks.filter(t=>t.side===n),sec=tr.reduce((a,t)=>a+(t.sec||0),0)+gapSec(tr.length);
|
||
return {n,rpm,type,sec,count:tr.length,st:type!=="audio"?"ok":!tr.length?"warn":sec<=L[0]?"ok":sec<=L[1]?"warn":"bad",lim:L};});
|
||
}
|
||
function sortTracks(){const o=sideNames();S.tracks.sort((a,b)=>o.indexOf(a.side)-o.indexOf(b.side));}
|
||
function autoSplit(){
|
||
const names=audioSides(),k=names.length,a=S.tracks.map(t=>t.sec||0),n=a.length;if(!n||!k)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 (copied from artist-order.html) ---- */
|
||
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;
|
||
}
|
||
/* ---- artwork: spine, templates, PDF and AI files (copied from artist-order.html) ----
|
||
12" panels 315 mm: 633 x 315 mm with a 3 mm spine, 635 x 315 with 5 mm (Mighty Media Discs templates); 3 mm holds one
|
||
record, 5 mm two (Implant, Jungle Record Press); gatefold 7 mm (RPM Records), 9 mm for two (XVinylX), all checked October 2026.
|
||
10" and 7" panels and the labels follow SIZE_MM; 3 mm bleed and 5 mm safe area are this demo's choice; hole 7.24 mm, dinked 38.2 mm.
|
||
Inner sleeves: 305 / 255 / 180 mm square, typical sizes this demo assumes; the packaging partner's dieline decides. */
|
||
const TPL={panel:{12:315,10:260,7:184},inner:{12:305,10:255,7:180},bleed:3,safe:5,hole:7.24,dink:38.2};
|
||
const singleJ=()=>S.jacket==="printed"||S.jacket==="plain";
|
||
const spineForced=()=>singleJ()&&S.discs>1&&S.size!=="7";
|
||
const thickOn=()=>singleJ()&&S.size!=="7"&&(S.spine==="thick"||S.discs>1);
|
||
function spineMM(){if(S.jacket==="none")return 0;if(S.jacket==="gatefold")return S.discs>1?9:7;return thickOn()?5:3;}
|
||
function spineHint(){return S.jacket==="none"?"You supply the jacket.":S.jacket==="gatefold"?"The gatefold has a "+spineMM()+" mm spine.":S.size==="7"?"7\" sleeves have a 3 mm spine.":thickOn()?(spineForced()?"5 mm spine: needed for "+S.discs+" records"+(S.discs>2?" (3 or 4 records in one jacket: the jacket is checked with the packaging partner)":"")+".":"5 mm spine: room for a thick insert or a poster."):"3 mm spine: one record in its inner sleeve.";}
|
||
function tplDims(k){const B=TPL.bleed;
|
||
if(k==="label"){const d=SIZE_MM[S.size].lab;return {d,txt:d+" mm label, "+(d+2*B)+" mm with bleed, a page per side ("+sideNames().join(", ")+")"};}
|
||
if(k==="inner"){const P=TPL.inner[S.size];return {P,w:P,h:P,txt:P+" × "+P+" mm, "+(P+2*B)+" mm with bleed (typical; the partner's dieline decides)"};}
|
||
const P=TPL.panel[S.size],sp=spineMM(),w=2*P+sp;
|
||
return {P,sp,w,h:P,txt:(w+2*B)+" × "+(P+2*B)+" mm with bleed, "+sp+" mm spine"+(S.jacket==="gatefold"?", outside and inside":"")};}
|
||
const tplOff=k=>k==="jacket"?(finishOn()?"":S.jacket==="none"?"You supply the jacket":"A plain jacket has no print"):k==="inner"?(S.inner==="printed"?"":"Only a printed inner sleeve has artwork"):(S.labels==="printed"?"":"Plain labels have no print");
|
||
|
||
/* templates as vector PDF, written here: trim and bleed boxes set, guides on the page */
|
||
function pdfStr(s){return "("+String(s).replace(/[^\x20-\x7e]/g,"?").replace(/[\\()]/g,m=>"\\"+m)+")";}
|
||
function pdfBlob(pages,title){
|
||
const objs=[],add=s=>objs.push(s);
|
||
add("");add("");add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
||
add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
|
||
add("<< /Title "+pdfStr(title)+" /Creator (CNX Vinyl demo template, working name) >>");
|
||
const kids=[],bx=a=>"["+a.map(v=>v.toFixed(2)).join(" ")+"]";
|
||
for(const p of pages){const c=add("<< /Length "+p.ops.length+" >>\nstream\n"+p.ops+"\nendstream");
|
||
kids.push(add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 "+p.w.toFixed(2)+" "+p.h.toFixed(2)+"] /TrimBox "+bx(p.trim)+" /BleedBox "+bx(p.bleed)+" /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents "+c+" 0 R >>"));}
|
||
objs[0]="<< /Type /Catalog /Pages 2 0 R >>";objs[1]="<< /Type /Pages /Kids ["+kids.map(k=>k+" 0 R").join(" ")+"] /Count "+kids.length+" >>";
|
||
let out="%PDF-1.4\n";const off=[];
|
||
objs.forEach((o,i)=>{off.push(out.length);out+=(i+1)+" 0 obj\n"+o+"\nendobj\n";});
|
||
const xr=out.length;
|
||
out+="xref\n0 "+(objs.length+1)+"\n0000000000 65535 f \n"+off.map(o=>String(o).padStart(10,"0")+" 00000 n \n").join("")+"trailer\n<< /Size "+(objs.length+1)+" /Root 1 0 R /Info 5 0 R >>\nstartxref\n"+xr+"\n%%EOF\n";
|
||
return new Blob([out],{type:"application/pdf"});
|
||
}
|
||
function tplPages(k){
|
||
const K=72/25.4,B=TPL.bleed,SF=TPL.safe,M=16,MB=30,TOP=14,f=v=>(v*K).toFixed(2),pages=[];
|
||
const ST={trim:"0 0 0 RG 0.7 w [] 0 d",bleed:"0.85 0.12 0.12 RG 0.6 w [3 2] 0 d",safe:"0.1 0.45 0.85 RG 0.6 w [1.5 1.5] 0 d",fold:"0.55 0.2 0.7 RG 0.6 w [4 2] 0 d",mark:"0 0 0 RG 0.3 w [] 0 d"};
|
||
const wrap=(s,mm,size)=>{const per=Math.max(20,Math.floor(mm*K/(size*0.5))),L=[];let ln="";for(const w of s.split(" ")){if((ln+" "+w).trim().length>per){L.push(ln);ln=w;}else ln=(ln+" "+w).trim();}if(ln)L.push(ln);return L;};
|
||
function page(tw,th,draw,head,foot){
|
||
const W=tw+2*B+2*M,H=th+2*B+M+MB+TOP,ox=M+B,oy=MB+B;let o="";
|
||
const P=(x,y)=>f(ox+x)+" "+f(oy+y);
|
||
const g={
|
||
rect:(x,y,w,h,s)=>{o+="q "+ST[s]+" "+P(x,y)+" "+f(w)+" "+f(h)+" re S Q\n";},
|
||
line:(x1,y1,x2,y2,s)=>{o+="q "+ST[s]+" "+P(x1,y1)+" m "+P(x2,y2)+" l S Q\n";},
|
||
circ:(cx,cy,r,s,fill)=>{const c=0.5523*r;o+="q "+(fill?fill+" g ":"")+ST[s]+" "+P(cx+r,cy)+" m "+P(cx+r,cy+c)+" "+P(cx+c,cy+r)+" "+P(cx,cy+r)+" c "+P(cx-c,cy+r)+" "+P(cx-r,cy+c)+" "+P(cx-r,cy)+" c "+P(cx-r,cy-c)+" "+P(cx-c,cy-r)+" "+P(cx,cy-r)+" c "+P(cx+c,cy-r)+" "+P(cx+r,cy-c)+" "+P(cx+r,cy)+" c "+(fill?"B":"S")+" Q\n";},
|
||
text:(x,y,size,s,op)=>{op=op||{};const x0=op.c?x-String(s).length*size*0.25/K:x;o+="q "+(op.grey||"0.25")+" g BT /"+(op.b?"F2":"F1")+" "+size+" Tf 1 0 0 1 "+P(x0,y)+" Tm "+pdfStr(s)+" Tj ET Q\n";}
|
||
};
|
||
draw(g);
|
||
[[0,0],[tw,0],[0,th],[tw,th]].forEach(([x,y])=>{const sx=x?1:-1,sy=y?1:-1;g.line(x+sx*(B+2),y,x+sx*(B+9),y,"mark");g.line(x,y+sy*(B+2),x,y+sy*(B+9),"mark");});
|
||
let y=th+B+M+TOP-7;const tm=tw+2*B+2*M-12;
|
||
head.forEach((s,i)=>wrap(s,tm,i?6.5:9).forEach(l=>{g.text(-B-M+6,y,i?6.5:9,l,{b:!i,grey:i?"0.3":"0.1"});y-=i?3:4.2;}));
|
||
y=-B-14;foot.forEach(s=>wrap(s,tm,6).forEach(l=>{g.text(-B-M+6,y,6,l,{grey:"0.35"});y-=2.8;}));
|
||
pages.push({w:W*K,h:H*K,trim:[ox*K,oy*K,(ox+tw)*K,(oy+th)*K],bleed:[(ox-B)*K,(oy-B)*K,(ox+tw+B)*K,(oy+th+B)*K],ops:o});
|
||
}
|
||
const foot=["Put your artwork on a layer under these guides and hide the guides before you export. Deliver a PDF (PDF/X-4 preferred) or an Illustrator file saved with PDF compatibility; CMYK; images 300 dpi at print size; text outlined or fonts embedded.",
|
||
"Demo template: sizes follow published plant templates (3 mm spine for one record, 5 mm for two). The production template comes from the packaging partner. CNX Vinyl is a working name."];
|
||
if(k==="jacket"){
|
||
const D=tplDims("jacket"),P=D.P,sp=D.sp,w=D.w,h=D.h,gf=S.jacket==="gatefold",nm=S.size+"\" "+(gf?"gatefold jacket":"jacket");
|
||
const spread=inside=>page(w,h,g=>{
|
||
g.rect(-B,-B,w+2*B,h+2*B,"bleed");g.rect(0,0,w,h,"trim");g.line(P,-B,P,h+B,"fold");g.line(P+sp,-B,P+sp,h+B,"fold");
|
||
g.rect(SF,SF,P-2*SF,h-2*SF,"safe");g.rect(P+sp+SF,SF,P-2*SF,h-2*SF,"safe");
|
||
g.text(P/2,h/2,16,inside?"INSIDE LEFT":"BACK",{c:1,b:1,grey:"0.78"});g.text(P+sp+P/2,h/2,16,inside?"INSIDE RIGHT":"FRONT",{c:1,b:1,grey:"0.78"});
|
||
g.text(P+sp/2,h+B+3,6,"spine "+sp+" mm",{c:1});
|
||
},["CNX Vinyl demo template: "+nm+", "+sp+" mm spine"+(gf?(inside?", page 2: inside":", page 1: outside"):""),
|
||
"Trim "+w+" x "+h+" mm: "+(inside?"inside left "+P+", spine "+sp+", inside right "+P:"back "+P+", spine "+sp+", front "+P)+" mm. Bleed "+B+" mm (red), safe area "+SF+" mm inside the trim (blue), folds (purple)."],foot);
|
||
spread(false);if(gf)spread(true);
|
||
}else{
|
||
const d=SIZE_MM[S.size].lab,r=d/2,dk=dinkOn(),hole=dk?TPL.dink:TPL.hole,keep=dk?3:8;
|
||
sideNames().forEach(sd=>page(d,d,g=>{
|
||
g.circ(r,r,r+B,"bleed");g.circ(r,r,r,"trim");g.circ(r,r,r-SF,"safe");g.circ(r,r,hole/2+keep,"safe");g.circ(r,r,hole/2,"trim","0.85");
|
||
g.text(r,r+hole/2+keep+4,8,"SIDE "+sd,{c:1,b:1,grey:"0.7"});
|
||
},["CNX Vinyl demo template: "+S.size+"\" label, side "+sd,
|
||
"Label "+d+" mm across, "+(d+2*B)+" mm with bleed (red). Centre hole "+hole+" mm"+(dk?" (dinked)":"")+": keep text "+keep+" mm clear of it and "+SF+" mm inside the edge (blue). One page per side."],foot));
|
||
}
|
||
return pages;
|
||
}
|
||
function downloadTpl(k){
|
||
if(tplOff(k)||k==="inner")return;
|
||
const name="cnx-demo-template-"+S.size+"in-"+(k==="label"?"labels-"+sideNames().join(""):(S.jacket==="gatefold"?"gatefold":"jacket")+"-"+spineMM()+"mm-spine")+".pdf";
|
||
const a=document.createElement("a");a.href=URL.createObjectURL(pdfBlob(tplPages(k),name.slice(0,-4)));a.download=name;document.body.appendChild(a);a.click();
|
||
setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},5000);toast("Template downloaded: "+name);
|
||
}
|
||
/* PDF and Illustrator files: read in this browser. Page boxes, colour spaces and fonts come from the file
|
||
(object streams inflated with DecompressionStream); the preview is rendered with pdf.js, loaded only when a PDF arrives. */
|
||
const lat=u=>new TextDecoder("latin1").decode(u);
|
||
async function inflate(u){const rd=new Blob([u]).stream().pipeThrough(new DecompressionStream("deflate")).getReader(),ch=[];let n=0;
|
||
try{for(;;){const x=await rd.read();if(x.done)break;ch.push(x.value);n+=x.value.length;}}catch(_){}
|
||
const o=new Uint8Array(n);let p=0;for(const c of ch){o.set(c,p);p+=c.length;}return o;}
|
||
async function pdfFacts(file){
|
||
const u8=new Uint8Array(await file.arrayBuffer()),head=lat(u8.subarray(0,4096)),a={name:file.name,kind:"pdf",ai:/\.ai$/i.test(file.name)};
|
||
if(/^%!PS-Adobe/.test(head)){a.kind="ps";return a;}
|
||
const vm=/%PDF-(\d\.\d)/.exec(head);if(!vm){a.kind="bad";return a;}
|
||
a.ver=vm[1];let T=lat(u8);const re=/\/Type\s*\/ObjStm/g,extra=[];let m,n=0;
|
||
while((m=re.exec(T))&&n<500){n++;let s=T.indexOf("stream",m.index);if(s<0)break;const d0=T.slice(Math.max(0,m.index-400),s);s+=6;if(T.charCodeAt(s)===13)s++;if(T.charCodeAt(s)===10)s++;
|
||
let e=T.indexOf("endstream",s);if(e<0)break;re.lastIndex=e;while(e>s&&(T.charCodeAt(e-1)===10||T.charCodeAt(e-1)===13))e--;
|
||
if(/FlateDecode/.test(d0))extra.push(lat(await inflate(u8.subarray(s,e))));}
|
||
T+="\n"+extra.join("\n");
|
||
const box=(src,nm)=>{const x=new RegExp("\\/"+nm+"\\s*\\[([^\\]]+)\\]").exec(src);if(!x)return null;const v=x[1].trim().split(/[\s,]+/).map(Number);return v.length===4&&v.every(isFinite)?v:null;};
|
||
const pm=/\/Type\s*\/Page(?![a-zA-Z])/.exec(T),win=pm?T.slice(Math.max(0,pm.index-1500),pm.index+1500):"";
|
||
a.media=box(win,"MediaBox")||box(T,"MediaBox");a.trimB=box(win,"TrimBox");a.bleedB=box(win,"BleedBox");
|
||
let c=0;for(const x of T.matchAll(/\/Count\s+(\d+)/g))c=Math.max(c,+x[1]);a.pages=c||1;
|
||
a.pdfx=/GTS_PDFX/.test(T);
|
||
const cr=/<xmp:CreatorTool>([^<]{1,80})</.exec(T)||/\/Creator\s*\(([^)]{1,80})\)/.exec(T);a.creator=cr?cr[1]:"";
|
||
a.ai=a.ai||/Illustrator/i.test(a.creator)||/AIPrivateData/.test(T);
|
||
a.cmyk=/\/DeviceCMYK|\/Separation|\/DeviceN/.test(T)||/\/N\s+4\b/.test(T);a.rgb=/\/DeviceRGB/.test(T)||/\/N\s+3\b/.test(T);
|
||
a.fonts=(T.match(/\/Type\s*\/Font\b/g)||[]).length;a.emb=(T.match(/\/FontFile[23]?\b/g)||[]).length;
|
||
a.u8=u8;return a;
|
||
}
|
||
let PDFJS=null;
|
||
async function pdfjs(){if(!PDFJS){const u="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/",m=await import(u+"pdf.min.mjs");m.GlobalWorkerOptions.workerSrc=u+"pdf.worker.min.mjs";PDFJS=m;}return PDFJS;}
|
||
function crop(src,sx,sy,sw,sh,max){const s=Math.min(1,max/Math.max(sw,sh)),c=document.createElement("canvas");c.width=Math.max(1,Math.round(sw*s));c.height=Math.max(1,Math.round(sh*s));c.getContext("2d").drawImage(src,sx,sy,sw,sh,0,0,c.width,c.height);return c.toDataURL("image/jpeg",0.88);}
|
||
async function pdfPreview(a,u8,k){
|
||
try{const m=await pdfjs(),doc=await m.getDocument({data:u8,isEvalSupported:false}).promise,pg=await doc.getPage(1),v1=pg.getViewport({scale:1}),vp=pg.getViewport({scale:Math.min(4,1600/Math.max(v1.width,v1.height))});
|
||
const cv=document.createElement("canvas");cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);const cx=cv.getContext("2d");cx.fillStyle="#fff";cx.fillRect(0,0,cv.width,cv.height);
|
||
await pg.render({canvasContext:cx,viewport:vp}).promise;
|
||
const q=vp.convertToViewportRectangle(a.trimB||a.media||pg.view),L=Math.min(q[0],q[2]),R=Math.max(q[0],q[2]),T=Math.min(q[1],q[3]),h=Math.max(q[1],q[3])-T;
|
||
a.url=k==="jacket"&&(R-L)/h>1.5?crop(cv,R-h,T,h,h,800):crop(cv,L,T,R-L,h,800);doc.destroy();
|
||
}catch(e){a.noPrev=true;}
|
||
}
|
||
const mmOf=b=>b?[(b[2]-b[0])*25.4/72,(b[3]-b[1])*25.4/72]:null;
|
||
function pdfChecks(k,a){
|
||
const out=[],B=TPL.bleed,r1=v=>Math.round(v*10)/10,near=(x,y)=>Math.abs(x-y)<=1;
|
||
out.push(["ok",(a.ai?"Illustrator file, saved PDF-compatible":"PDF "+a.ver)+(a.pdfx?", PDF/X":"")]);
|
||
const tr=mmOf(a.trimB),md=mmOf(a.media),sz=tr||md||[0,0],tw=sz[0],th=sz[1];let bl=null;
|
||
if(tr){const ref=a.bleedB||a.media;bl=Math.min(a.trimB[0]-ref[0],a.trimB[1]-ref[1],ref[2]-a.trimB[2],ref[3]-a.trimB[3])*25.4/72;}
|
||
const fit=(W,H)=>{if(tr)return near(tw,W)&&near(th,H)?"trim":"";if(near(tw,W+2*B)&&near(th,H+2*B)){bl=B;return "bleed";}if(near(tw,W)&&near(th,H)){bl=0;return "trim";}return "";};
|
||
if(k==="label"){const d=SIZE_MM[S.size].lab,ns=sideNames().length,ok=fit(d,d);
|
||
out.push(ok?["ok",(ok==="trim"?"Trim ":"Page ")+r1(tw)+" mm: the "+S.size+"\" label"+(ok==="bleed"?" with 3 mm bleed":"")]:["bad",(tr?"Trim ":"Page ")+r1(tw)+" × "+r1(th)+" mm; the "+S.size+"\" label is "+d+" mm ("+(d+2*B)+" with bleed)"]);
|
||
out.push(a.pages>=ns?["ok",a.pages+" page"+(a.pages>1?"s":"")+": side"+(ns>1?"s ":" ")+sideNames().join(", ")]:["warn",a.pages+" page"+(a.pages>1?"s":"")+" for "+ns+" sides: side "+sideNames().slice(a.pages).join(", ")+" missing"]);
|
||
}else if(k==="inner"){const D=tplDims("inner"),ok=fit(D.P,D.P);
|
||
out.push(ok?["ok",r1(ok==="bleed"?tw-2*B:tw)+" mm square: the "+S.size+"\" inner sleeve"]:["warn",r1(tw)+" × "+r1(th)+" mm; the demo assumes "+D.P+" mm square (the partner's dieline decides)"]);
|
||
}else{const D=tplDims("jacket"),ok=fit(D.w,D.h),hh=ok==="bleed"?th-2*B:th,ww=ok==="bleed"?tw-2*B:tw;
|
||
if(ok)out.push(["ok",r1(ww)+" × "+r1(hh)+" mm: back, "+D.sp+" mm spine, front"]);
|
||
else if(fit(D.P,D.P))out.push(["warn","Front only: the back and the "+D.sp+" mm spine are needed before print (use the template)"]);
|
||
else{const H=near(th,D.h)?th:near(th,D.h+2*B)?th-2*B:0,W=H===th?tw:tw-2*B,sp=W-2*D.P;
|
||
out.push(H&&sp>0.5&&sp<12?["bad","The spread fits a "+Math.round(sp)+" mm spine; this order has "+D.sp+" mm. Use the template"]:["bad",r1(tw)+" × "+r1(th)+" mm does not match the template ("+D.w+" × "+D.h+" mm trim)"]);}
|
||
if(S.jacket==="gatefold")out.push(a.pages>=2?["ok","Page 2: the inside spread"]:["warn","Gatefold: the inside spread is needed as page 2"]);
|
||
}
|
||
out.push(bl==null?["warn","No trim box: the bleed could not be checked"]:bl>=B-0.2?["ok",r1(bl)+" mm bleed"]:["bad",(bl<=0.2?"No bleed":r1(bl)+" mm bleed")+": 3 mm needed"]);
|
||
out.push(a.cmyk&&!a.rgb?["ok","CMYK"]:a.cmyk?["warn","RGB and CMYK mixed: convert to CMYK"]:a.rgb?["warn","RGB: the printer converts it to CMYK, and bright colours can shift"]:["pend","Colour checked on the proof: no colour space declared in the file"]);
|
||
out.push(!a.fonts?["ok","No live text: outlined"]:a.emb?["ok","Fonts embedded"]:["bad","Fonts not embedded: outline the text or embed the fonts"]);
|
||
return out;
|
||
}
|
||
function rasterChecks(k,a){
|
||
const out=[],B=TPL.bleed;let mmH;
|
||
if(k==="label"){mmH=SIZE_MM[S.size].lab+2*B;out.push(Math.abs(a.w/a.h-1)<0.01?["ok","Square, for side A"]:["bad","Not square ("+a.w+" × "+a.h+" px)"]);}
|
||
else if(k==="inner"){mmH=TPL.inner[S.size]+2*B;out.push(Math.abs(a.w/a.h-1)<0.01?["ok","Square, the sleeve front"]:["warn","Not square ("+a.w+" × "+a.h+" px): an inner sleeve is square"]);}
|
||
else{const D=tplDims("jacket"),r=a.w/a.h;mmH=D.h+2*B;
|
||
if(r>1.5){const wmm=a.w/(a.h/mmH)-2*B,sp=wmm-2*D.P;
|
||
out.push(Math.abs(wmm-D.w)<=1.2?["ok","Spread: back, "+D.sp+" mm spine, front"]:sp>0.5&&sp<12?["bad","The spread fits a "+Math.round(sp)+" mm spine; this order has "+D.sp+" mm. Use the template"]:["bad","Spread "+Math.round(wmm)+" mm wide; the template is "+D.w+" mm plus bleed"]);
|
||
if(S.jacket==="gatefold")out.push(["warn","Gatefold: the inside spread is needed too (page 2 of a PDF)"]);}
|
||
else if(Math.abs(r-1)<0.01)out.push(["warn","Front only: the back and the "+D.sp+" mm spine are needed before print (use the template)"]);
|
||
else out.push(["bad","Neither the spread nor a square front ("+a.w+" × "+a.h+" px)"]);}
|
||
const need=Math.round(mmH/25.4*300),dpi=Math.round(a.h/(mmH/25.4));
|
||
out.push(a.h>=need?["ok",dpi+" dpi at "+mmH+" mm high"]:a.h>=need*0.8?["warn",dpi+" dpi, a little under 300 (needs "+need+" px high)"]:["bad",dpi+" dpi: too low for print (needs "+need+" px high)"]);
|
||
out.push(a.cmyk?["ok","CMYK"]:["warn","RGB: the printer converts it to CMYK, and bright colours can shift"]);
|
||
return out;
|
||
}
|
||
async function readImage(file,k){
|
||
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;
|
||
const a={kind:"img",url,w:img.naturalWidth,h:img.naturalHeight,cmyk:comps===4,name:file.name};
|
||
if(k==="jacket"&&a.w/a.h>1.5)a.url=crop(img,a.w-a.h,0,a.h,a.h,800);
|
||
return a;
|
||
}
|
||
function artChecks(k){
|
||
const a=S.art[k];if(!a)return null;if(a.err)return [["pend",a.err]];
|
||
if(a.kind==="ps")return [["bad","Illustrator file saved without PDF content: save it with Create PDF Compatible File, or export a PDF"]];
|
||
if(a.kind==="bad")return [["bad","Not a PDF, AI or image file"]];
|
||
return a.kind==="pdf"?pdfChecks(k,a):rasterChecks(k,a);
|
||
}
|
||
const artThumb=a=>a.url?`<img src="${a.url}" alt="">`:`<span class="aph">${a.kind==="ps"||a.ai?"AI":a.kind==="pdf"?"PDF":"?"}</span>`;
|
||
const artNote=a=>a.loading?`<li><span class="st pend">○</span> Rendering a preview</li>`:a.noPrev?`<li><span class="st pend">○</span> No preview: pdf.js could not be loaded. The checks above read the file itself.</li>`:"";
|
||
async function handleArt(k,files){
|
||
if(tplOff(k))return;
|
||
const f=files.find(f=>/^image\//.test(f.type)||/\.(jpe?g|png|tiff?|webp|pdf|ai)$/i.test(f.name)||/pdf|postscript|illustrator/.test(f.type));if(!f)return;
|
||
const vec=/\.(pdf|ai)$/i.test(f.name)||/pdf|postscript|illustrator/.test(f.type);
|
||
let a;try{a=vec?await pdfFacts(f):await readImage(f,k);}catch(err){a={name:f.name,err:"This browser cannot read the file; the real platform checks it after upload."};}
|
||
const u8=a.u8;delete a.u8;S.art[k]=a;if(a.kind==="pdf")a.loading=true;
|
||
renderAll();
|
||
if(a.kind==="pdf"){await pdfPreview(a,u8,k);a.loading=false;if(S.art[k]===a)renderAll();}
|
||
}
|
||
|
||
/* ---- automatic checks (checkList of artist-order.html, per side speed and side type) ---- */
|
||
function artItem(k,name){const c=artChecks(k);return !c?["pend",name,"Not added yet: it can follow after the order"]:c.some(x=>x[0]==="bad")?["bad",name,c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:c.some(x=>x[0]!=="ok")?["warn",name,c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:["ok",name,"Size, resolution and colour space fine"];}
|
||
function checkList(){
|
||
const L=[],files=S.tracks.filter(t=>t.file),ready=files.filter(t=>t.info&&!t.info.reading);
|
||
if(rep())L.push(["pend","Repress","Pressed from the archived mother: the audio is the archived title's. A change to the music needs a new cut, so a new release."]);
|
||
if(!S.tracks.length)L.push(["bad","Track list","Add at least one track."]);
|
||
if(lq())L.push(["pend","Lacquer","Cut by the customer's studio and sent by express courier the day it is cut: a lacquer has to be plated within days. Inspected under the microscope on arrival. Files are for the side plan and previews only."]);
|
||
else if(S.source==="tape")L.push(["pend","Tape","Analog tape is transferred and checked on arrival; format, speed and EQ curve are confirmed with the customer first."]);
|
||
if(S.source!=="digital"||rep()){}else 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().filter(s=>s.type==="audio"),bad=ss.filter(s=>s.st==="bad"),warn=ss.filter(s=>s.st==="warn"&&s.count),empty=ss.filter(s=>!s.count);
|
||
L.push(!ss.length?["bad","Side length","No side has audio"]:bad.length?["bad","Side length",bad.map(s=>"side "+s.n+" "+mmss(s.sec)+" (at most "+mmss(s.lim[1])+" at "+rpmT(s.rpm)+")").join(", ")+". Move tracks, add a disc or change the speed."]
|
||
:warn.length?["warn","Side length",warn.map(s=>"side "+s.n+" "+mmss(s.sec)+" past the comfortable "+mmss(s.lim[0])).join(", ")+", 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 its comfortable length"]);
|
||
if(finishOn())L.push(artItem("jacket","Jacket artwork"));
|
||
if(S.labels==="printed")L.push(artItem("label","Label artwork"));
|
||
if(S.inner==="printed")L.push(artItem("inner","Inner sleeve artwork"));
|
||
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 at gate 1."]);
|
||
return L;
|
||
}
|
||
|
||
/* ---- drawing: discs, jackets, sleeves (SVG strings; vinyl colours are data and go in attributes, theme colours in classes) ---- */
|
||
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;};}
|
||
let UID=0;
|
||
const hexOf=k=>COL[k]?COL[k][1]:"#151515";
|
||
const trOf=k=>!!(COL[k]&&COL[k][2]);
|
||
const fa=k=>`fill="${hexOf(k)}"`+(trOf(k)?` fill-opacity="${k==="clear"?0.32:0.66}"`:"");
|
||
function lum(h){const n=parseInt(h.slice(1),16);return (0.299*(n>>16)+0.587*(n>>8&255)+0.114*(n&255))/255;}
|
||
function baseKey(d){const c=d.c[d.fx]||["black"];return d.fx==="cic"?c[1]:c[0];}
|
||
function darkDisc(d){const k=baseKey(d);return trOf(k)?k==="clear"?false:lum(hexOf(k))<0.5:lum(hexOf(k))<0.42;}
|
||
/* the coloured body of a disc in unit coordinates (radius 105) */
|
||
function discBody(d,id,detail,seed){
|
||
const c=d.c[d.fx]||["black"],R0=105;let defs="",b="";
|
||
const anyTr=c.some(trOf);
|
||
if(anyTr){defs+=`<pattern id="${id}ck" width="10" height="10" patternUnits="userSpaceOnUse"><rect width="10" height="10" fill="#bfc6cf"/><rect width="5" height="5" fill="#8e97a3"/><rect x="5" y="5" width="5" height="5" fill="#8e97a3"/></pattern>`;b+=`<circle r="${R0}" fill="url(#${id}ck)" opacity="0.30"/>`;}
|
||
switch(d.fx){
|
||
case "solid":case "trans":b+=`<circle r="${R0}" ${fa(c[0])}/>`;break;
|
||
case "split":b+=`<path d="M0 -105 A105 105 0 0 0 0 105 Z" ${fa(c[0])}/><path d="M0 -105 A105 105 0 0 1 0 105 Z" ${fa(c[1])}/>`;
|
||
if(detail)b+=`<line x1="0" y1="-105" x2="0" y2="105" stroke="#fff" stroke-opacity="0.18" stroke-width="0.8"/>`;break;
|
||
case "splatter":{b+=`<circle r="${R0}" ${fa(c[0])}/>`;const sp=c.slice(1).filter(x=>x&&x!=="none");if(!sp.length)break;
|
||
const r=rnd(seed||11),N=detail?64:22;let dots="";
|
||
for(let i=0;i<N;i++){const a=r()*6.283,dd=Math.sqrt(r())*99,s=1.4+Math.pow(r(),2)*(detail?6.5:8),k=sp[i%sp.length],x=Math.cos(a)*dd,y=Math.sin(a)*dd;
|
||
dots+=`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${s.toFixed(1)}" ${fa(k)}/>`;
|
||
const m=detail?3:1;for(let j=0;j<m;j++){const a2=r()*6.283,d2=s*(1.1+r()*1.3);dots+=`<circle cx="${(x+Math.cos(a2)*d2).toFixed(1)}" cy="${(y+Math.sin(a2)*d2).toFixed(1)}" r="${(s*(0.18+r()*0.3)).toFixed(1)}" ${fa(k)}/>`;}}
|
||
defs+=`<clipPath id="${id}c"><circle r="${R0}"/></clipPath>`;b+=`<g clip-path="url(#${id}c)">${dots}</g>`;break;}
|
||
case "marbled":b+=`<circle r="${R0}" ${fa(c[0])}/>`;
|
||
defs+=`<clipPath id="${id}c"><circle r="${R0}"/></clipPath><filter id="${id}m" x="0" y="0" width="1" height="1" color-interpolation-filters="sRGB"><feTurbulence type="turbulence" baseFrequency="0.018" numOctaves="2" seed="${(seed||3)%97}" result="t"/><feTurbulence type="fractalNoise" baseFrequency="0.011 0.045" numOctaves="3" seed="${((seed||3)*7)%97}" result="n"/><feDisplacementMap in="n" in2="t" scale="34" xChannelSelector="R" yChannelSelector="G" result="dn"/><feColorMatrix in="dn" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9 0 0 0 -4.2" result="mk"/><feFlood flood-color="${hexOf(c[1])}" result="f"/><feComposite in="f" in2="mk" operator="in"/></filter>`;
|
||
b+=`<g clip-path="url(#${id}c)"><rect x="-105" y="-105" width="210" height="210" filter="url(#${id}m)" transform="rotate(28)"/></g>`;break;
|
||
case "cic":b+=`<circle r="${R0}" ${fa(c[1])}/>`;
|
||
defs+=`<filter id="${id}b" x="-30%" y="-30%" width="160%" height="160%"><feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="2" seed="${(seed||5)%97}" result="t"/><feDisplacementMap in="SourceGraphic" in2="t" scale="12" xChannelSelector="R" yChannelSelector="G" result="d"/><feGaussianBlur in="d" stdDeviation="${detail?1.6:1}"/></filter>`;
|
||
b+=`<circle r="64" ${fa(c[0])} filter="url(#${id}b)"/>`;break;
|
||
default:b+=`<circle r="${R0}" fill="#151515"/>`;
|
||
}
|
||
return {defs,b};
|
||
}
|
||
/* a small disc for tiles, tabs and swatch previews */
|
||
function miniDisc(d,px,o){o=o||{};const id="m"+(++UID),B=discBody(d,id,false,o.seed||11),dk=darkDisc(d);
|
||
const lab=o.label==="white"?"#f3efe6":o.label==="black"?"#1c1c1c":o.label==="none"?null:"#efe6d2";
|
||
return `<svg width="${px}" height="${px}" viewBox="-108 -108 216 216" aria-hidden="true"><defs>${B.defs}<mask id="${id}h"><rect x="-110" y="-110" width="220" height="220" fill="#fff"/><circle r="${o.dink?19:5}" fill="#000"/></mask></defs><g mask="url(#${id}h)">${B.b}`+
|
||
`<circle r="${(105*0.72).toFixed(1)}" fill="none" stroke="${dk?"rgba(255,255,255,0.10)":"rgba(0,0,0,0.12)"}" stroke-width="2"/><circle r="${(105*0.5).toFixed(1)}" fill="none" stroke="${dk?"rgba(255,255,255,0.08)":"rgba(0,0,0,0.10)"}" stroke-width="2"/>`+
|
||
(lab?`<circle r="${o.lr||35}" fill="${lab}"/>${o.label==="printed"?`<circle r="${(o.lr||35)*0.7}" fill="none" stroke="#c0392b" stroke-width="4"/>`:""}`:"")+
|
||
`<circle r="104" fill="none" stroke="rgba(0,0,0,0.35)" stroke-width="2"/></g></svg>`;}
|
||
const sizeDisc=(sz,px)=>miniDisc(newDisc(),Math.round(px*SIZE_MM[sz].rec/302),{lr:105*SIZE_MM[sz].lab/SIZE_MM[sz].rec,label:"printed"});
|
||
function sleeveMini(kind,col,px){const w=px,u="sl"+(++UID),c=kind==="printed"?"url(#"+u+")":col==="black"?"#1d1d1d":"#f2efe8",st=col==="black"&&kind!=="printed"?"#8a8a8a":"#c8c2b6";
|
||
return `<svg width="${w}" height="${w}" viewBox="0 0 40 40" aria-hidden="true"><defs><linearGradient id="${u}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f4c430"/><stop offset="0.5" stop-color="#ef80b2"/><stop offset="1" stop-color="#1f5fbf"/></linearGradient></defs>`+
|
||
`<rect x="9" y="3" width="30" height="30" rx="${kind==="poly"?3:1}" fill="#151515"/><rect x="2" y="6" width="31" height="31" rx="${kind==="poly"?3:0.6}" fill="${c}" stroke="${st}" stroke-width="0.8"/>`+
|
||
(kind==="poly"?`<rect x="4" y="8" width="27" height="27" rx="2" fill="#fff" fill-opacity="0.18"/><path d="M5 30 L28 9" stroke="#fff" stroke-opacity="0.6" stroke-width="1.2"/>`:"")+
|
||
`<circle cx="17.5" cy="21.5" r="${kind==="printed"?0:5}" fill="#151515"/><circle cx="17.5" cy="21.5" r="${kind==="printed"?0:2}" fill="#efe6d2"/></svg>`;}
|
||
function jacketMini(kind,px){
|
||
const s=px,u="jm"+(++UID),body=kind==="none"?`<rect x="5" y="5" width="30" height="30" fill="none" stroke="currentColor" stroke-dasharray="3 2" opacity="0.6"/>`
|
||
:kind==="plain"?`<rect x="5" y="5" width="30" height="30" fill="#d6c09b"/><rect x="3.4" y="5" width="1.6" height="30" fill="#a88f68"/>`
|
||
:kind==="gatefold"?`<polygon points="5,5 -3,3.5 -3,36.5 5,35" fill="#e8e2d6"/><rect x="5" y="5" width="30" height="30" fill="url(#${u})"/><rect x="3.4" y="5" width="1.6" height="30" fill="#070b14"/>`
|
||
:`<rect x="5" y="5" width="30" height="30" fill="url(#${u})"/><rect x="${kind==="printed5"?2.4:3.6}" y="5" width="${kind==="printed5"?2.6:1.4}" height="30" fill="#070b14"/>`;
|
||
return `<svg width="${s}" height="${s}" viewBox="-4 0 44 40" aria-hidden="true"><defs><linearGradient id="${u}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#c0392b"/><stop offset="1" stop-color="#0d1322"/></linearGradient></defs>${body}${kind!=="none"&&kind!=="plain"?`<circle cx="25" cy="15" r="6" fill="none" stroke="#fff" stroke-opacity="0.35" stroke-width="2"/>`:""}</svg>`;}
|
||
function finishMini(kind,px){
|
||
const u="fm"+(++UID),g=kind==="gloss"?`<polygon points="5,5 24,5 5,24" fill="#fff" opacity="0.38"/>`:kind==="spotuv"?`<circle cx="25" cy="15" r="6" fill="none" stroke="#fff" stroke-opacity="0.95" stroke-width="2.6"/><rect x="9" y="27" width="16" height="3" fill="#fff" opacity="0.8"/>`:kind==="matt"?`<rect x="5" y="5" width="30" height="30" fill="#fff" opacity="0.08"/>`:`<rect x="5" y="5" width="30" height="30" fill="url(#${u}p)" opacity="0.5"/>`;
|
||
return `<svg width="${px}" height="${px}" viewBox="0 0 40 40" aria-hidden="true"><defs><linearGradient id="${u}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1f5fbf"/><stop offset="1" stop-color="#0d1322"/></linearGradient><pattern id="${u}p" width="3" height="3" patternUnits="userSpaceOnUse"><rect width="1" height="1" fill="#fff"/></pattern></defs><rect x="5" y="5" width="30" height="30" fill="url(#${u})"/><circle cx="25" cy="15" r="6" fill="none" stroke="#fff" stroke-opacity="0.3" stroke-width="2"/>${g}</svg>`;}
|
||
|
||
/* ---- the stage: one big record drawn to scale, with the jacket and sleeve behind it ---- */
|
||
const ACC=()=>{const d=discsOn().find(x=>!isBlack(x));if(!d)return "#c0392b";const k=baseKey(d);return k==="clear"||k==="white"||k==="bone"?"#c0392b":hexOf(k);};
|
||
function labelG(id,rl,sd,di){
|
||
const L=rl.toFixed(2);
|
||
if(S.labels==="white")return `<circle r="${L}" fill="#f3efe6"/><circle r="${(rl*0.9).toFixed(2)}" fill="none" stroke="rgba(0,0,0,0.06)" stroke-width="0.5"/>`;
|
||
if(S.labels==="black")return `<circle r="${L}" fill="#1c1c1c"/><circle r="${(rl*0.9).toFixed(2)}" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="0.5"/>`;
|
||
if(S.art.label&&S.art.label.url)return `<clipPath id="${id}lc"><circle r="${L}"/></clipPath><image href="${S.art.label.url}" x="-${L}" y="-${L}" width="${(2*rl).toFixed(2)}" height="${(2*rl).toFixed(2)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${id}lc)"/>`;
|
||
const fit=(s,max,f)=>Math.min(max,1.55*rl/Math.max(1,s.length*f));
|
||
const a=S.artist.slice(0,24),t=S.title.slice(0,30),fa1=fit(a,0.16*rl,0.58),ft=fit(t,0.105*rl,0.52),acc=ACC();
|
||
return `<circle r="${L}" fill="#efe6d2"/><circle r="${(rl*0.93).toFixed(2)}" fill="none" stroke="${acc}" stroke-width="${(rl*0.035).toFixed(2)}"/>`+
|
||
`<text y="${(-0.62*rl).toFixed(2)}" text-anchor="middle" font-size="${(0.075*rl).toFixed(2)}" letter-spacing="${(0.03*rl).toFixed(2)}" fill="#7a6f5c" font-weight="700">CNX VINYL</text>`+
|
||
`<text y="${(-0.34*rl).toFixed(2)}" text-anchor="middle" font-size="${fa1.toFixed(2)}" font-weight="800" fill="#222">${esc(a)}</text>`+
|
||
`<text y="${(-0.17*rl).toFixed(2)}" text-anchor="middle" font-size="${ft.toFixed(2)}" fill="#444">${esc(t)}</text>`+
|
||
`<text x="${(-0.5*rl).toFixed(2)}" y="${(0.06*rl).toFixed(2)}" text-anchor="middle" font-size="${(0.085*rl).toFixed(2)}" font-weight="700" fill="${acc}">SIDE ${sd}</text>`+
|
||
`<text x="${(0.5*rl).toFixed(2)}" y="${(0.06*rl).toFixed(2)}" text-anchor="middle" font-size="${(0.075*rl).toFixed(2)}" fill="#555">${rpmOf(sd)==="45"?"45 RPM":"33⅓ RPM"}</text>`+
|
||
`<text y="${(0.4*rl).toFixed(2)}" text-anchor="middle" font-size="${(0.08*rl).toFixed(2)}" fill="#555" font-family="ui-monospace,Consolas,monospace">${esc(S.catno||"")}</text>`+
|
||
`<text y="${(0.58*rl).toFixed(2)}" text-anchor="middle" font-size="${(0.055*rl).toFixed(2)}" fill="#8a7f6a">Made in Chiang Mai</text>`;
|
||
}
|
||
/* grooves for the side shown: program band filled in proportion to the side length against the at-most limit,
|
||
wider glossy bands between tracks, smooth lead-in, lead-out and dead wax with the scribed run-out text */
|
||
function groovesG(id,d,di,sc,u,rl){
|
||
const Z=SIZE_MM[S.size],sd=sideNames()[2*di]||"A",st=sideStats().find(x=>x.n===sd),dk=darkDisc(d);
|
||
const gA=dk?"rgba(255,255,255,0.075)":"rgba(0,0,0,0.11)",gB=dk?"rgba(255,255,255,0.035)":"rgba(0,0,0,0.05)",band=dk?"rgba(255,255,255,0.15)":"rgba(255,255,255,0.38)",bandE=dk?"rgba(0,0,0,0.55)":"rgba(0,0,0,0.14)";
|
||
const ps=105-3.2*u,pmin=(Z.lab/2+10)*u,stepU=Math.max(0.55,2.9/sc),sw=(0.9/sc).toFixed(3);
|
||
let g=`<circle r="104.3" fill="none" stroke="${dk?"rgba(255,255,255,0.12)":"rgba(0,0,0,0.2)"}" stroke-width="${(1.2/sc).toFixed(3)}"/>`;
|
||
g+=`<circle r="${(ps+1.2*u).toFixed(2)}" fill="none" stroke="${band}" stroke-width="${(1.4*u).toFixed(2)}"/>`;
|
||
if(st&&st.type==="audio"&&st.count){
|
||
const f=Math.max(0.12,Math.min(1,st.sec/st.lim[1])),pe=ps-(ps-pmin)*f,tr=S.tracks.filter(t=>t.side===sd),tot=Math.max(1,st.sec);
|
||
let i=0;for(let r=ps;r>pe;r-=stepU,i++)g+=`<circle r="${r.toFixed(2)}" fill="none" stroke="${i%2?gB:gA}" stroke-width="${sw}"/>`;
|
||
if(S.gaps!=="none"){let acc=0;tr.slice(0,-1).forEach(t=>{acc+=(t.sec||0)+(S.gaps==="add"?2:0);const r=ps-(ps-pe)*acc/tot;
|
||
g+=`<circle r="${r.toFixed(2)}" fill="none" stroke="${band}" stroke-width="${(1.3*u).toFixed(2)}"/><circle r="${(r-0.65*u).toFixed(2)}" fill="none" stroke="${bandE}" stroke-width="${sw}"/>`;});}
|
||
for(let j=1;j<=3;j++){const r=pe-(pe-pmin)*j/3.2;g+=`<circle r="${r.toFixed(2)}" fill="none" stroke="${gA}" stroke-width="${sw}"/>`;}
|
||
}else{g+=`<circle r="${((ps+pmin)/2).toFixed(2)}" fill="none" stroke="${gB}" stroke-width="${((ps-pmin)*0.9).toFixed(2)}"/>`;}
|
||
g+=`<circle r="${pmin.toFixed(2)}" fill="none" stroke="${gA}" stroke-width="${(1.4/sc).toFixed(3)}"/>`;
|
||
const rt=(rl+(pmin-rl)*0.45).toFixed(2),fs=((pmin-rl)*0.4).toFixed(2),ink=dk?"rgba(255,255,255,0.55)":"rgba(0,0,0,0.5)";
|
||
const msg=(!lq()&&!rep()&&S.leadout)?S.leadout:"";
|
||
if(S.catno||msg)g+=`<path id="${id}ro" d="M0 -${rt} A${rt} ${rt} 0 1 1 -0.01 -${rt}" fill="none"/><text font-size="${fs}" font-family="ui-monospace,Consolas,monospace" letter-spacing="0.3" fill="${ink}">${S.catno?`<textPath href="#${id}ro" startOffset="3%">${esc(S.catno)} ${sd}</textPath>`:""}</text>`+(msg?`<text font-size="${fs}" font-family="ui-monospace,Consolas,monospace" letter-spacing="0.2" fill="${ink}" font-style="italic"><textPath href="#${id}ro" startOffset="30%">${esc(msg)}</textPath></text>`:"");
|
||
return g;
|
||
}
|
||
function recordG(d,di,cx,cy,k,detail){
|
||
const Z=SIZE_MM[S.size],id="h"+di+"x",sc=Z.rec/2*k/105,u=105/(Z.rec/2),rl=Z.lab/2*u,B=discBody(d,id,detail,11+di*13);
|
||
const hole=(dinkOn()?TPL.dink/2:TPL.hole/2)*u,sd=sideNames()[2*di]||"A",dur=rpmOf(sd)==="45"?1.333:1.8;
|
||
let defs=B.defs+`<mask id="${id}h" maskUnits="userSpaceOnUse" x="-170" y="-170" width="340" height="340"><rect x="-170" y="-170" width="340" height="340" fill="#fff"/><circle r="${hole.toFixed(2)}" fill="#000"/></mask>`+
|
||
`<radialGradient id="${id}sh" gradientUnits="userSpaceOnUse" cx="-30" cy="-38" r="150"><stop offset="0" stop-color="#fff" stop-opacity="0.16"/><stop offset="0.55" stop-color="#fff" stop-opacity="0"/></radialGradient>`+
|
||
`<radialGradient id="${id}wg" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="105"><stop offset="0.3" stop-color="#fff" stop-opacity="0"/><stop offset="0.62" stop-color="#fff" stop-opacity="0.15"/><stop offset="1" stop-color="#fff" stop-opacity="0.03"/></radialGradient>`+
|
||
`<filter id="${id}ds" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="4"/></filter>`;
|
||
const wedge=(a1,a2,r1,r2)=>{const p=(r,a)=>(r*Math.cos(a)).toFixed(2)+" "+(r*Math.sin(a)).toFixed(2);return `M${p(r1,a1)} L${p(r2,a1)} A${r2} ${r2} 0 0 1 ${p(r2,a2)} L${p(r1,a2)} A${r1} ${r1} 0 0 0 ${p(r1,a1)} Z`;};
|
||
const W1=-2.25,W2=0.89,dw=0.16,r1=rl+2;
|
||
let inner=`<rect x="-160" y="-160" width="320" height="320" fill="none"/>`+B.b;
|
||
if(detail)inner+=groovesG(id,d,di,sc,u,rl);
|
||
else inner+=`<circle r="104.3" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>`;
|
||
inner+=labelG(id,rl,sd,di);
|
||
const sheen=detail?`<g mask="url(#${id}h)" pointer-events="none"><path d="${wedge(W1-dw,W1+dw,r1,104.5)}" fill="url(#${id}wg)"/><path d="${wedge(W2-dw,W2+dw,r1,104.5)}" fill="url(#${id}wg)"/><circle r="105" fill="url(#${id}sh)"/></g>`:`<circle r="105" fill="url(#${id}sh)" mask="url(#${id}h)"/>`;
|
||
return {defs,g:`<g transform="translate(${cx.toFixed(1)} ${cy.toFixed(1)}) scale(${sc.toFixed(4)})"${detail?` data-hero="${di}" data-d="${(210*sc).toFixed(1)}"`:""}><circle cx="2" cy="5" r="105" fill="#000" opacity="${detail?0.4:0.3}" filter="url(#${id}ds)" mask="url(#${id}h)"/><g class="spin" style="--dur:${dur}s" mask="url(#${id}h)">${inner}</g>${sheen}</g>`};
|
||
}
|
||
/* jacket with its spine, finish, shrink and extras; x, y = top-left of the front, J = side in px, k = px per mm */
|
||
function jacketG(x,y,J,k,o){
|
||
o=o||{};const F=v=>v.toFixed(1),sp=spineMM()*k,gf=S.jacket==="gatefold",pr=finishOn(),art=S.art.jacket&&S.art.jacket.url&≺
|
||
let defs=`<linearGradient id="jg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${ACC()}"/><stop offset="1" stop-color="#0d1322"/></linearGradient>`+
|
||
`<linearGradient id="jgl" x1="0" y1="0" x2="1" y2="1"><stop offset="0.12" stop-color="#fff" stop-opacity="0"/><stop offset="0.3" stop-color="#fff" stop-opacity="0.34"/><stop offset="0.42" stop-color="#fff" stop-opacity="0"/></linearGradient>`+
|
||
`<linearGradient id="jin" x1="1" y1="0" x2="0" y2="0"><stop offset="0" stop-color="#bdb4a3"/><stop offset="1" stop-color="#efe9de"/></linearGradient>`+
|
||
`<filter id="jsh" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${F(Math.max(2,J*0.012))}"/></filter>`+
|
||
`<filter id="jpap" x="0" y="0" width="1" height="1"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="2"/><feColorMatrix type="matrix" values="0 0 0 0 0.5 0 0 0 0 0.45 0 0 0 0 0.4 0 0 0 0.35 0"/><feComposite in2="SourceGraphic" operator="in"/></filter>`+
|
||
`<clipPath id="jclip"><rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}"/></clipPath>`;
|
||
let s="";
|
||
if(S.jacket==="none")return {defs,g:`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" fill="none" class="stl" stroke-width="1.5" stroke-dasharray="8 6"/><text x="${F(x+J/2)}" y="${F(y+J*0.12)}" text-anchor="middle" font-size="${F(Math.max(11,J*0.04))}" class="stx">your own jacket</text>`};
|
||
if(S.insert)s+=`<rect x="${F(x+0.1*J)}" y="${F(y-0.07*J)}" width="${F(0.62*J)}" height="${F(0.5*J)}" fill="#f7f4ec" stroke="#d6cfc0" stroke-width="0.8" transform="rotate(-2 ${F(x+0.4*J)} ${F(y)})"/><text x="${F(x+0.14*J)}" y="${F(y-0.03*J)}" font-size="${F(J*0.028)}" fill="#8a7f6a" font-weight="700" transform="rotate(-2 ${F(x+0.4*J)} ${F(y)})">INSERT · LYRICS · CREDITS</text>`;
|
||
if(S.dlcard)s+=`<rect x="${F(x+0.6*J)}" y="${F(y-0.1*J)}" width="${F(85*k)}" height="${F(55*k)}" rx="${F(3*k)}" fill="#1b2a44" stroke="#3a4d70" stroke-width="0.8" transform="rotate(4 ${F(x+0.6*J)} ${F(y)})"/><text x="${F(x+0.62*J)}" y="${F(y-0.06*J)}" font-size="${F(J*0.026)}" fill="#9fd0ff" font-weight="700" transform="rotate(4 ${F(x+0.6*J)} ${F(y)})">DOWNLOAD CODE</text>`;
|
||
s+=`<rect x="${F(x+4-sp)}" y="${F(y+8)}" width="${F(J+sp)}" height="${F(J)}" fill="#000" opacity="0.35" filter="url(#jsh)"/>`;
|
||
if(gf){const w=0.3*J;s+=`<polygon points="${F(x-sp)},${F(y)} ${F(x-sp-w)},${F(y-0.035*J)} ${F(x-sp-w)},${F(y+J*1.035)} ${F(x-sp)},${F(y+J)}" fill="url(#jin)" stroke="rgba(0,0,0,0.25)" stroke-width="0.8"/>`+
|
||
`<circle cx="${F(x-sp-w*0.5)}" cy="${F(y+J*0.5)}" r="${F(J*0.12)}" fill="none" stroke="${ACC()}" stroke-opacity="0.35" stroke-width="${F(J*0.012)}"/>`;}
|
||
if(sp)s+=`<rect x="${F(x-sp)}" y="${F(y)}" width="${F(sp)}" height="${F(J)}" fill="${S.jacket==="plain"?"#a88f68":"#070b14"}"/><line x1="${F(x-sp+0.5)}" y1="${F(y)}" x2="${F(x-sp+0.5)}" y2="${F(y+J)}" stroke="#fff" stroke-opacity="0.18"/>`;
|
||
if(S.jacket==="plain")s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" fill="#d6c09b"/><rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" filter="url(#jpap)" opacity="0.5"/>`;
|
||
else if(art)s+=`<image href="${S.art.jacket.url}" x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" preserveAspectRatio="xMidYMid slice"/>`;
|
||
else{const ti=S.title.slice(0,40),wd=ti.split(" "),ln=[""];wd.forEach(w=>{const c=ln[ln.length-1];if(c&&(c+" "+w).length>13&&ln.length<2)ln.push(w);else ln[ln.length-1]=(c?c+" ":"")+w;});
|
||
const fs=Math.min(J*0.075,J*0.46/Math.max(1,Math.max(...ln.map(l=>l.length))*0.58)),ty=y+0.84*J-(ln.length-1)*fs*1.05;
|
||
s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" fill="url(#jg)"/><circle cx="${F(x+0.68*J)}" cy="${F(y+0.34*J)}" r="${F(0.2*J)}" fill="none" stroke="#fff" stroke-opacity="0.26" stroke-width="${F(0.045*J)}" class="ds"/>`+(o.peek?"":
|
||
ln.map((l,i)=>`<text x="${F(x+0.075*J)}" y="${F(ty+i*fs*1.05)}" font-size="${F(fs)}" font-weight="800" fill="#fff">${esc(l)}</text>`).join("")+`<text x="${F(x+0.075*J)}" y="${F(y+0.92*J)}" font-size="${F(Math.min(J*0.048,J*0.46/Math.max(1,S.artist.length*0.55)))}" fill="rgba(255,255,255,0.82)">${esc(S.artist.slice(0,30))}</text>`);}
|
||
if(pr){
|
||
if(S.finish==="uncoated")s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" filter="url(#jpap)" opacity="0.55"/>`;
|
||
else if(S.finish==="matt")s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" fill="#fff" opacity="0.07"/>`;
|
||
else if(S.finish==="gloss")s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" fill="url(#jgl)"/>`;
|
||
else s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(J)}" height="${F(J)}" fill="#fff" opacity="0.05"/>`+(art?`<g clip-path="url(#jclip)" opacity="0.5">${Array.from({length:7},(_,i)=>`<circle cx="${F(x+J*(0.12+i*0.13))}" cy="${F(y+J*(0.2+(i%3)*0.27))}" r="${F(J*0.04)}" fill="url(#jgl)" stroke="#fff" stroke-opacity="0.5"/>`).join("")}</g>`
|
||
:`<circle cx="${F(x+0.68*J)}" cy="${F(y+0.34*J)}" r="${F(0.2*J)}" fill="none" stroke="#fff" stroke-opacity="0.85" stroke-width="${F(0.045*J)}"/><rect x="${F(x+0.07*J)}" y="${F(y+0.79*J)}" width="${F(0.55*J)}" height="${F(0.06*J)}" fill="url(#jgl)"/>`);
|
||
}
|
||
if(S.obi){const w=0.11*J;s+=`<rect x="${F(x)}" y="${F(y)}" width="${F(w)}" height="${F(J)}" fill="#f6f2e8"/><rect x="${F(x)}" y="${F(y)}" width="${F(w)}" height="${F(J*0.16)}" fill="#c62828"/><text transform="translate(${F(x+w*0.62)} ${F(y+J*0.22)}) rotate(90)" font-size="${F(w*0.42)}" font-weight="700" fill="#333">${esc(S.artist.slice(0,18).toUpperCase())}</text>`;}
|
||
if(S.sticker)s+=`<circle cx="${F(x+J*0.86)}" cy="${F(y+J*0.12)}" r="${F(J*0.075)}" fill="#ffd23f"/><text x="${F(x+J*0.86)}" y="${F(y+J*0.135)}" text-anchor="middle" font-size="${F(J*0.04)}" font-weight="800" fill="#222">NEW</text>`;
|
||
if(S.barcode)s+=`<g opacity="0.85">${Array.from({length:14},(_,i)=>`<rect x="${F(x+J*0.8+i*J*0.0085)}" y="${F(y+J*0.86)}" width="${F(J*(i%3?0.003:0.006))}" height="${F(J*0.07)}" fill="#111"/>`).join("")}</g>`;
|
||
if(S.numbered)s+=`<text x="${F(x+J*0.94)}" y="${F(y+J*0.83)}" text-anchor="end" font-size="${F(J*0.032)}" fill="#fff" opacity="0.9" font-style="italic">No. 001 / ${fmtInt(S.qty)}</text>`;
|
||
if(S.shrink)s+=`<polygon points="${F(x)},${F(y)} ${F(x+0.42*J)},${F(y)} ${F(x)},${F(y+0.42*J)}" fill="#fff" opacity="0.08"/><path d="M${F(x+J*0.97)} ${F(y+J*0.05)} q ${F(-J*0.02)} ${F(J*0.2)} 0 ${F(J*0.4)}" stroke="#fff" stroke-opacity="0.25" fill="none"/>`;
|
||
if(S.bag){const m=5*k;s+=`<rect x="${F(x-sp-m)}" y="${F(y-m*3)}" width="${F(J+sp+2*m)}" height="${F(J+m*4)}" rx="${F(m*0.6)}" fill="#fff" fill-opacity="0.07" stroke="#fff" stroke-opacity="0.45" stroke-width="1"/><line x1="${F(x-sp-m)}" y1="${F(y-m)}" x2="${F(x+J+m)}" y2="${F(y-m)}" stroke="#fff" stroke-opacity="0.4" stroke-dasharray="4 3"/>`;}
|
||
return {defs,g:s};
|
||
}
|
||
function sleeveG(x,y,Js){
|
||
const F=v=>v.toFixed(1),pr=S.inner==="printed",blk=S.innerCol==="black"&&!pr,poly=S.inner==="poly";
|
||
let s=`<linearGradient id="slv" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f4c430"/><stop offset="0.5" stop-color="#ef80b2"/><stop offset="1" stop-color="#1f5fbf"/></linearGradient>`,g="";
|
||
const fill=pr?(S.art.inner&&S.art.inner.url?"#fff":"url(#slv)"):blk?"#1d1d1d":"#f1eee7";
|
||
g+=`<rect x="${F(x+3)}" y="${F(y+6)}" width="${F(Js)}" height="${F(Js)}" fill="#000" opacity="0.25" filter="url(#jsh)"/><rect x="${F(x)}" y="${F(y)}" width="${F(Js)}" height="${F(Js)}" rx="${poly?F(Js*0.02):0}" fill="${fill}" stroke="${blk?"#3a3a3a":"#cfc8b9"}" stroke-width="0.8"/>`;
|
||
if(pr&&S.art.inner&&S.art.inner.url)g+=`<image href="${S.art.inner.url}" x="${F(x)}" y="${F(y)}" width="${F(Js)}" height="${F(Js)}" preserveAspectRatio="xMidYMid slice"/>`;
|
||
if(poly)g+=`<rect x="${F(x+Js*0.03)}" y="${F(y+Js*0.03)}" width="${F(Js*0.94)}" height="${F(Js*0.94)}" rx="${F(Js*0.02)}" fill="#fff" fill-opacity="0.16"/><path d="M${F(x+Js*0.55)} ${F(y+Js*0.95)} L${F(x+Js*0.97)} ${F(y+Js*0.4)}" stroke="#fff" stroke-opacity="0.55" stroke-width="2"/>`;
|
||
if(!pr)g+=`<circle cx="${F(x+Js*0.5)}" cy="${F(y+Js*0.5)}" r="${F(Js*0.17)}" fill="#141414"/>`;
|
||
return {defs:s,g};
|
||
}
|
||
function renderStage(){
|
||
const el=$("#rec"),W=el.clientWidth,H=el.clientHeight;if(!W||!H)return;
|
||
const ph=window.innerWidth<900,jv=S.view==="jacket",Z=SIZE_MM[S.size],Z12=SIZE_MM["12"],F=v=>v.toFixed(1);
|
||
const top=ph?30:12,bot=ph?40:48,ah=H-top-bot,aw=W,vd=Math.min(S.vd,S.discs-1),hero=S.dl[vd];
|
||
let defs="",body="",ghost="";
|
||
const others=discsOn().map((d,i)=>[d,i]).filter(x=>x[1]!==vd).reverse();
|
||
if(!jv){
|
||
const k=Math.min(aw*(ph?0.92:0.86),ah*0.96)/Z12.rec,D=Z.rec*k,J=Z.jk*k;
|
||
const cx=ph?W/2:Math.max(D/2+8,W/2-Math.min(40,(W-D)/2)),cy=top+ah/2;
|
||
if(S.jacket!=="none"){const jx=cx-J/2-0.3*J,jy=cy-J/2-0.36*J,jk=jacketG(jx,jy,J,k,{peek:true});defs+=jk.defs;body+=jk.g;}
|
||
others.forEach(([d,i],n)=>{const off=(others.length-n)*0.05*D,r=recordG(d,i,cx+off,cy-off*0.6,k,false);defs+=r.defs;body+=r.g;});
|
||
const r=recordG(hero,vd,cx,cy,k,true);defs+=r.defs;body+=r.g;
|
||
if(S.size!=="12"){const R12=Z12.rec/2*k;ghost=`<circle cx="${F(cx)}" cy="${F(cy)}" r="${F(R12)}" fill="none" class="stl" stroke-width="1.3" stroke-dasharray="7 6"/><text x="${F(cx)}" y="${F(cy-R12+16)}" text-anchor="middle" font-size="12" class="stx">12" record, for scale</text>`;}
|
||
}else{
|
||
const gfw=0.3,cw=Z12.jk*(1.5+(S.jacket==="gatefold"?gfw+0.03:0)+0.02),ch=Z12.jk*1.1;
|
||
const k=Math.min(aw*0.94/cw,ah*0.94/ch),J=Z.jk*k,D=Z.rec*k,J12=Z12.jk*k;
|
||
const x0=(W-cw*k)/2+(S.jacket==="gatefold"?(gfw+0.03)*J12:0.02*J12),yb=ph?top+ah/2+J12/2:Math.max(top+ah/2+J12/2,top+ah-0.02*J12);
|
||
const jx=x0,jy=yb-J,cx=jx+J+0.02*J,cy=yb-J/2;
|
||
if(S.size!=="12")ghost=`<rect x="${F(x0)}" y="${F(yb-J12)}" width="${F(J12)}" height="${F(J12)}" fill="none" class="stl" stroke-width="1.3" stroke-dasharray="7 6"/><text x="${F(x0+8)}" y="${F(yb-J12+18)}" font-size="12" class="stx">12" jacket, for scale</text>`;
|
||
const sl=sleeveG(jx+0.26*J,jy-0.035*J,0.97*J);defs+=sl.defs;body+=`<g transform="rotate(-4 ${F(jx+0.75*J)} ${F(jy+0.5*J)})">${sl.g}</g>`;
|
||
const jk=jacketG(jx,jy,J,k);defs+=jk.defs;body+=jk.g;
|
||
others.forEach(([d,i],n)=>{const off=(others.length-n)*0.07*D,r=recordG(d,i,cx+off,cy,k,false);defs+=r.defs;body+=r.g;});
|
||
const r=recordG(hero,vd,cx,cy,k,true);defs+=r.defs;body+=r.g;
|
||
}
|
||
el.innerHTML=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc("Picture of the order: "+descT())}"><defs>${defs}</defs>${ghost}${body}</svg>`;
|
||
const sp=spineMM();
|
||
$("#sCap").textContent=Z.name+": record "+Z.rec+" mm, label "+Z.lab+" mm"+(S.jacket!=="none"?", jacket "+Z.jk+" mm"+(sp?", spine "+sp+" mm":""):"")+" · hover to spin";
|
||
const chips=[Z.name].concat(S.size==="12"?[S.weight+" g"]:[],[fxName(hero),rpmT(rpmOf(sideNames()[2*vd]||"A"))]);
|
||
$("#sinfo").innerHTML=chips.map(c=>`<span>${esc(c)}</span>`).join("")+`<span class="${FX[hero.fx].dx?"dx":""}" title="${esc(FX[hero.fx].dx?"Effects run on the Duplex system: its second extruder (EX 35) adds the second colour; the Classic has one built-in extruder (pressing line)":"Black, solid and translucent colours run on the Classic or the Duplex system (pressing line)")}">${FX[hero.fx].dx?"Duplex system only":"Classic or Duplex"}</span>`;
|
||
$("#sDiscs").innerHTML=S.discs>1?discsOn().map((d,i)=>`<button type="button" class="${i===vd?"on":""}" data-act="dtab" data-i="${i}" title="Show disc ${i+1}">Disc ${i+1}</button>`).join(""):"";
|
||
$("#sDiscs").hidden=S.discs<2;
|
||
}
|
||
|
||
/* ---- steps ---- */
|
||
const STEPS=[["type","Order type"],["size","Size"],["discs","Discs"],["colour","Vinyl colour"],["labels","Centre labels"],["inner","Inner sleeves"],["jacket","Jacket"],["finish","Jacket finish"],["extras","Extras"],["cut","Mastering & cutting"],["tp","Test pressings"],["ship","Shipping"],["tracks","Tracklist & audio"],["art","Artwork"],["pay","How to pay"],["review","Review & order"]];
|
||
const PAY=STEPS.findIndex(s=>s[0]==="pay")+1,RV=STEPS.length;
|
||
const stT=n=>STEPS[n-1][0]==="review"&&fansOn()?"Review & launch":STEPS[n-1][1];
|
||
const JVIEW=[6,7,8,9,14];
|
||
const IC={
|
||
type:'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2.5"/>',
|
||
size:'<rect x="2.5" y="8" width="19" height="8" rx="1.5"/><path d="M6 8v3M9.5 8v4M13 8v3M16.5 8v4"/>',
|
||
discs:'<ellipse cx="12" cy="7" rx="8" ry="3"/><path d="M4 7v4c0 1.7 3.6 3 8 3s8-1.3 8-3V7M4 11v4c0 1.7 3.6 3 8 3s8-1.3 8-3v-4"/>',
|
||
colour:'<path d="M12 3a9 9 0 1 0 0 18c1.4 0 2-1 2-2s-1-1.6-1-2.6S14 15 15.5 15H18a3 3 0 0 0 3-3c0-5-4-9-9-9z"/><circle cx="7.5" cy="11" r="1.2"/><circle cx="10" cy="7" r="1.2"/><circle cx="15" cy="7.5" r="1.2"/>',
|
||
labels:'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="0.8"/>',
|
||
inner:'<rect x="3" y="4" width="16" height="16" rx="1"/><circle cx="14" cy="12" r="5.5"/><circle cx="14" cy="12" r="1.5"/>',
|
||
jacket:'<rect x="3" y="3" width="18" height="18" rx="1.5"/><path d="M3 15l5-5 4 4 3-3 6 6"/>',
|
||
finish:'<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
|
||
extras:'<circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/>',
|
||
cut:'<path d="M4 20h16"/><circle cx="12" cy="14" r="4"/><path d="M15 5l-3 9M15 5h4"/>',
|
||
tp:'<path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/><path d="M7 15h10"/>',
|
||
ship:'<path d="M2 6h12v10H2zM14 9h4l3 3v4h-7z"/><circle cx="6" cy="17.5" r="1.8"/><circle cx="17" cy="17.5" r="1.8"/>',
|
||
tracks:'<path d="M3 6h12M3 11h12M3 16h7"/><path d="M17 17V7l4-1"/><circle cx="15.5" cy="17" r="1.8"/>',
|
||
art:'<rect x="3" y="4" width="18" height="16" rx="1.5"/><circle cx="8.5" cy="9" r="1.8"/><path d="M3 17l5-4 4 3 4-5 5 5"/>',
|
||
review:'<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
||
pay:'<rect x="2.5" y="6" width="19" height="13" rx="2"/><path d="M2.5 10.5h19"/><circle cx="16.5" cy="15" r="1.6"/>',
|
||
fans:'<circle cx="8.5" cy="8" r="3"/><path d="M2.5 20c0-3.4 2.7-6 6-6s6 2.6 6 6"/><circle cx="17" cy="9" r="2.4"/><path d="M15.6 14.3c3 .2 5.9 2.4 5.9 5.7"/>',
|
||
plant:'<path d="M3 20V10.5l5 3v-3l5 3V5h4.5v15z"/><path d="M2 20h20M17.5 5h3v15"/>',
|
||
vault:'<rect x="4.5" y="10" width="15" height="10.5" rx="2"/><path d="M8 10V7.5a4 4 0 0 1 8 0V10"/><circle cx="12" cy="15" r="1.4"/>',
|
||
refund:'<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
||
card:'<rect x="2.5" y="5.5" width="19" height="13" rx="2"/><path d="M2.5 9.5h19M6 15h4"/>',
|
||
loop:'<path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3"/><path d="M18 3v4h-4M6 21v-4h4"/>',
|
||
print:'<path d="M7 9V3h10v6M7 17H4v-7h16v7h-3"/><rect x="7" y="14" width="10" height="7"/>',
|
||
file:'<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 13h6M9 17h6"/>',
|
||
tape:'<rect x="2.5" y="6" width="19" height="12" rx="2"/><circle cx="8" cy="12" r="2.2"/><circle cx="16" cy="12" r="2.2"/><path d="M8 14.2h8"/>',
|
||
pickup:'<path d="M3 21V9l9-6 9 6v12"/><path d="M9 21v-6h6v6"/>',
|
||
globe:'<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/>',
|
||
studio:'<path d="M4 20V9l8-5 8 5v11"/><path d="M8 20v-6h8v6M10 11h4"/>',
|
||
none:'<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>'
|
||
};
|
||
const icon=(k,s)=>`<svg viewBox="0 0 24 24" width="${s||18}" height="${s||18}" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${IC[k]}</svg>`;
|
||
const tile=(v,inner,tt)=>`<button type="button" class="tile" data-v="${v}"${tt?` data-tt="${esc(tt)}"`:""}>${inner}</button>`;
|
||
const ptag=k=>`<span class="pr" data-pt="${k}"></span>`;
|
||
const meters=v=>`<div class="meters">${["LEVEL","DYNAMICS","DISTORTION"].map((n,i)=>`<span>${n}</span><span class="mtr">${[1,2,3,4,5].map(j=>`<i class="${j<=v[i]?"f":""}"></i>`).join("")}</span>`).join("")}</div>`;
|
||
const fld=(id,lab,type,o)=>`<label class="fld" id="f_${id}"><span>${lab}${o&&o.opt?" <small>(optional)</small>":""}</span><input type="${type||"text"}" id="${id}" autocomplete="${o&&o.ac||"off"}"${o&&o.ml?` maxlength="${o.ml}"`:""}></label>`;
|
||
const addrF=g=>`<div class="grid2f">${fld("a_"+g+"_name","Name","text",{ac:"name"})}${g==="b"?fld("a_b_company","Company or label","text",{opt:1,ac:"organization"}):fld("a_"+g+"_phone","Phone","tel",{ac:"tel"})}</div>`+
|
||
(g==="b"?`<div class="grid2f">${fld("a_b_email","E-mail","email",{ac:"email"})}${fld("a_b_phone","Phone","tel",{ac:"tel"})}</div>`:"")+
|
||
fld("a_"+g+"_line1","Address line","text",{ac:"address-line1"})+`<div class="grid2f">${fld("a_"+g+"_city","City","text",{ac:"address-level2"})}${fld("a_"+g+"_postcode","Postcode","text",{ac:"postal-code"})}</div>`+fld("a_"+g+"_country","Country","text",{ac:"country-name"});
|
||
const PRESETS={
|
||
splat:{t:"Splatter 12\"",size:"12",discs:1,jacket:"printed",dl:[{fx:"splatter",c:["clear","red","yellow","black"]}]},
|
||
marble:{t:"Marble 7\"",size:"7",discs:1,dl:[{fx:"marbled",c:["blue","white"]}]},
|
||
split:{t:"Split double LP",size:"12",discs:2,jacket:"gatefold",dl:[{fx:"split",c:["red","yellow"]},{fx:"split",c:["blue","white"]}]}
|
||
};
|
||
const pDisc=p=>{const d=newDisc();d.fx=p.fx;d.c[p.fx]=p.c.slice();return d;};
|
||
function stepHTML(){
|
||
const H=[];
|
||
H[1]=`<div class="expl">What are we making? A <b>new release</b> is mastered, cut, plated and pressed here. A <b>repress</b> presses more copies of a record made here before, from the metal the plant archived with it.</div>
|
||
<div class="tiles c2" data-k="otype">${tile("new",icon("type",34)+"<b>New release</b><small>Master, cut, plate, press</small>")}${tile("repress",icon("loop",34)+"<b>Repress</b><small>New stampers from the archived mother</small>"+ptag("rep"))}${tile("tponly",'<span class="tag">later</span>'+icon("tp",34)+"<b>Test pressings only</b><small>A few records to check a master</small>")}${tile("print",'<span class="tag">later</span>'+icon("print",34)+"<b>Print only</b><small>Jackets and sleeves, no records</small>")}</div>
|
||
<div class="note info" id="otNote"></div>
|
||
<h4>Release</h4><div class="grid2f">${fld("artist","Artist","text",{ml:60})}${fld("title","Title","text",{ml:80})}</div><p class="hint">Printed on the demo label and jacket in the picture.</p>`;
|
||
H[2]=`<div class="expl">7", 10" or 12". The tiles and the picture are drawn to scale: records are <b>302, 250 and 175 mm</b> across (IEC 60098), and a smaller record shows a dashed 12" outline for reference.</div>
|
||
<div class="tiles" data-k="size">${["12","10","7"].map(z=>tile(z,`<span style="height:76px;display:grid;place-items:center">${sizeDisc(z,72)}</span><b>${z}"</b><small>${z==="12"?"LP":z==="10"?"10 inch":"single"} · ${SIZE_MM[z].rec} mm</small><small>up to ${LIM[z][z==="7"?"45":"33"][1]} min a side at ${z==="7"?"45":"33⅓"}</small>${ptag("sz"+z)}`)).join("")}</div>
|
||
<h4>Centre hole</h4><div class="seg" data-k="dinked" data-bool><button type="button" data-v="false">Small, 7.24 mm</button><button type="button" data-v="true">Large, dinked 38.2 mm</button></div><p class="hint" id="dinkHint"></p>`;
|
||
H[3]=`<div class="expl">How many records are in the set? Each disc gets its own colour in the next step. <b>Two or more</b> need the 5 mm spine or a gatefold jacket.</div>
|
||
<div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin:12px 0"><div class="stepper big"><button type="button" data-act="discs" data-d="-1" aria-label="One disc fewer" id="dMinus">−</button><b id="discsV"></b><button type="button" data-act="discs" data-d="1" aria-label="One disc more" id="dPlus">+</button></div><div id="discPic"></div></div>
|
||
<p id="discsHint"></p><p class="hint">Press setup is charged per disc (${"each disc has its own stampers"}); the side plan in step 13 spreads the tracks over every side.</p>`;
|
||
H[4]=`<div class="expl">Choose the weight, then a colour or an effect for each disc. Black, solid and translucent colours run on <b>either press</b>; every effect needs the <b>Duplex system</b>, whose second extruder adds the second colour (the Classic has one built-in extruder).</div>
|
||
<div class="try"><span>Try:</span>${Object.keys(PRESETS).map(k=>`<button type="button" class="chip" data-act="preset" data-p="${k}">${miniDisc(pDisc(PRESETS[k].dl[0]),22,{label:"none"})}${esc(PRESETS[k].t)}</button>`).join("")}</div>
|
||
<div id="dTabsW"><div class="dtabs" id="dTabs"></div></div>
|
||
<h4>Vinyl weight</h4><div class="tiles" data-k="weight" data-num>${tile("140","<b>140 g</b><small>Standard</small>"+ptag("w140"))}${tile("180","<b>180 g</b><small>Heavy, 12\" only</small>"+ptag("w180"))}<div class="tile" style="cursor:default" title="Weight changes the feel and the price; it does not by itself make a record sound better."><b>?</b><small>Heavier feels better in the hand; it does not change the sound by itself</small></div></div>
|
||
<h4>Colour and effect <span class="pbadge" id="pressB"></span></h4><div id="fxList"></div>
|
||
<h4>Press cleaning</h4><div class="seg" data-k="cleaning" data-bool><button type="button" data-v="true">Clean before the colour</button><button type="button" data-v="false">No cleaning</button></div><p class="hint" id="cleanHint"></p>`;
|
||
H[5]=`<div class="expl">Centre labels are paper labels pressed into the record with the vinyl. Printed labels carry your art (upload in step 14); plain ones are cheaper.</div>
|
||
<div class="tiles" data-k="labels">${[["printed","Printed","Your art, both sides"],["white","Plain white","No print"],["black","Plain black","No print"]].map(([v,t,s])=>tile(v,miniDisc(newDisc(),58,{label:v})+`<b>${t}</b><small>${s}</small>`+ptag("lab_"+v))).join("")}</div>
|
||
<p class="hint">Labels are pre-dried in the oven before they go to the press, and every label lot is checked against the approved proof (process flow).</p>`;
|
||
H[6]=`<div class="expl">The sleeve the record sits in, inside the jacket. Poly-lined sleeves protect the grooves best; a printed sleeve carries your art.</div>
|
||
<div class="tiles rows" data-k="innerT">${[["paper-white","paper","white","White paper","Plain paper, centre hole"],["paper-black","paper","black","Black paper","Plain paper, centre hole"],["poly-white","poly","white","Poly-lined, white","Anti-static lining, kindest to the grooves"],["poly-black","poly","black","Poly-lined, black","Anti-static lining"],["printed","printed","white","Printed inner sleeve","Your art on both sides (step 14)"]].map(([v,kd,c,t,s])=>tile(v,sleeveMini(kd,c,44)+`<span class="tx"><b>${t}</b><small>${s}</small></span>`+ptag("in_"+v))).join("")}</div>`;
|
||
H[7]=`<div class="expl">The outer jacket. Printed jackets come from a local packaging partner and are passed through at the partner's price plus a stated handling margin. The spine is <b>3 mm</b> for one record and <b>5 mm</b> for two, or for one with a thick insert.</div>
|
||
<div class="tiles rows" data-k="jk">${[["none","You supply the jacket","We sleeve the records; you bring the jackets"],["plain","Plain jacket","Unprinted card"],["printed3","Printed, 3 mm spine","Full colour, one record"],["printed5","Printed, 5 mm spine","Two records, or a thick insert"],["gatefold","Printed gatefold","Opens like a book, 12\" only"]].map(([v,t,s])=>tile(v,jacketMini(v,44)+`<span class="tx"><b id="jkt_${v}">${t}</b><small>${s}</small></span>`+ptag("jk_"+v))).join("")}</div><p class="hint" id="spineHint"></p>`;
|
||
H[8]=`<div class="expl">The surface of a printed jacket. Lamination protects the print; spot UV adds gloss to chosen parts of the art.</div>
|
||
<div class="tiles c2" data-k="finish">${[["uncoated","Uncoated","Paper feel"],["matt","Matt lamination","Soft, no glare"],["gloss","Gloss lamination","Bright and shiny"],["spotuv","Spot UV","Gloss on parts of the art"]].map(([v,t,s])=>tile(v,finishMini(v,46)+`<b>${t}</b><small>${s}</small>`+ptag("fin_"+v))).join("")}</div><p class="hint" id="finHint"></p>`;
|
||
H[9]=`<div class="expl">Extras that go on or into every copy. Each one shows in the picture where it would be visible on the real record.</div>
|
||
<div class="tiles rows">${[["shrink","Shrink wrap","Sealed in film"],["insert","Printed insert","Lyrics, credits, a poster"],["sticker","Hype sticker","Round sticker on the shrink"],["numbered","Hand-numbered","No. 001 of the run, written by hand"],["dlcard","Download card","Printed both sides, with a code"],["barcode","Barcode sticker","On the back of the jacket"],["obi","Obi strip","Printed band round the jacket"],["bag","Outer bag","PVC, resealable"]].map(([k,t,s])=>`<button type="button" class="tile xopt" data-act="tog" data-b="${k}"><span class="box">✓</span><span class="tx"><b>${t}</b><small>${s}</small></span>${ptag("x_"+k)}</button>`).join("")}</div>
|
||
<h4>Catalogue number</h4>${fld("catno","Scribed in the dead wax next to the run-out groove: A to Z, 0 to 9 and hyphen, up to 15","text",{ml:15})}<p class="hint">Needed to order, not for a quote. The demo starts with DEMO001.</p>`;
|
||
H[10]=`<div class="expl">How your audio arrives, who masters it, and how it is cut. Cutting is done in-house, on the plant's own lathe.</div>
|
||
<h4>Audio arrives as</h4><div class="tiles" data-k="source">${tile("digital",icon("file",30)+"<b>Digital files</b><small>WAV, FLAC or AIFF</small>"+ptag("src_digital"))}${tile("tape",icon("tape",30)+"<b>Analog tape</b><small>Transferred here</small>"+ptag("src_tape"))}${tile("lacquer",miniDisc({fx:"solid",c:{solid:["red"]}},30,{label:"none"})+"<b>Your own lacquers</b><small>Cut by your studio</small>"+ptag("src_lacquer"))}</div>
|
||
<h4>Mastering</h4><div class="tiles rows" data-k="master">${tile("none",icon("none",30)+`<span class="tx"><b>No mastering, my master is final</b><small>We only prepare it for the lathe</small></span>`+ptag("m_none"))}${tile("inhouse",icon("cut",30)+`<span class="tx"><b>Vinyl mastering in-house</b><small>Our engineer masters each side for vinyl; you approve the master (gate 2)</small></span>`+ptag("m_inhouse"))}${tile("partner",icon("studio",30)+`<span class="tx"><b>Full mastering by a partner studio</b><small><b>Brandenburg Mastering</b>, Amsterdam · 60 USD a track. The studio masters for the format; our cutting engineer gets the vinyl master. Example partner, not yet contracted.</small></span>`+ptag("m_partner"))}</div><p class="hint" id="mHint"></p>
|
||
<h4>Cutting style</h4><div class="tiles" data-k="cutStyle">${[["fidelity","Fidelity","Most dynamics",[2,5,1]],["balanced","Balanced","For most music",[3,4,2]],["loud","Loud","Club and bass-heavy",[5,2,3]]].map(([v,t,s,m])=>`<button type="button" class="tile cs" data-v="${v}"><b>${t}</b><small>${s}</small>${meters(m)}</button>`).join("")}</div><p class="hint" id="csHint"></p>
|
||
<h4>Cutting room</h4><div class="note info" id="cutRoom"></div>`;
|
||
H[11]=`<div class="expl">Test pressings are the first records from your stampers. You listen to them and approve them: that is <b>gate 3</b>, the last of your three approvals, and the run starts only after it.</div>
|
||
<div class="note ok"><b>We recommend 5.</b> One for you, one each for the mastering engineer and the label, and spares for the band. They are the records you approve at gate 3.</div>
|
||
<div class="tiles c4" data-k="tps" data-num>${[0,3,5,10].map(n=>tile(String(n),`<b>${n||"None"}</b><small>${n?"copies":"no approval"}</small>`+ptag("tp"+n))).join("")}</div>
|
||
<div class="note bad" id="tpWarn"><b>No test pressings?</b> Without test pressings we can only stand behind cutting and plating: you hear the pressing for the first time on the finished records, and a pressing fault found then is on the order.</div>
|
||
<h4>Questions</h4>
|
||
<details class="faq"><summary>What exactly do I approve?</summary><p>The plant has three artist approval gates: the master plan (gate 1), the vinyl master (gate 2) and the test pressing (gate 3). Nothing goes past a gate without your recorded approval, and the approvals live in the same traceability record as the process data (process flow).</p></details>
|
||
<details class="faq"><summary>What if I hear a problem?</summary><p>Tell us what and where (side, track, time). The engineer checks the test pressing against the master and the metal: a pressing issue is fixed at the press, a cut or plating issue goes back a stage before the run starts.</p></details>
|
||
<details class="faq"><summary>Does the plant check the records anyway?</summary><p>Yes. Every record is inspected optically after pressing, and a sample is played and compared with the master automatically (quality automation). Your test pressing approval is about how it sounds to you.</p></details>
|
||
<details class="faq"><summary>Does it slow the order down?</summary><p>The quoted lead time is counted from your test-pressing approval, because the plant cannot be held to a clock that waits for approvals (production timeline). Listen within a few days and the order keeps its slot.</p></details>`;
|
||
H[12]=`<div class="expl">Where the records go. Pick-up at the plant is free; delivery rates are estimates until carriers quote. Addresses are needed to order, not for a quote.</div>
|
||
<div class="note info" id="shipFan" hidden><b>Fan-funded (step ${PAY}):</b> every fan pays shipping at checkout, at cost, and gets one tracked parcel. The fans decide how many are pressed; the choice below is for your own copies and the test pressings.</div>
|
||
<div class="row"><span class="lbl">Copies</span><div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">−</button><input type="number" class="qtyIn" id="qtyShip" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div></div>
|
||
<div class="tiles" data-k="ship">${tile("pickup",icon("pickup",30)+"<b>Pick-up</b><small>At the plant, Chiang Mai</small>"+ptag("sh_pickup"))}${tile("th",icon("ship",30)+"<b>Thailand</b><small>Courier, per carton</small>"+ptag("sh_th"))}${tile("intl",icon("globe",30)+"<b>International</b><small>Air parcel, per kg</small>"+ptag("sh_intl"))}</div>
|
||
<div class="note info" id="shipCalc"></div>
|
||
<fieldset id="fsBill"><legend>Billing address</legend>${addrF("b")}</fieldset>
|
||
<label class="chk" id="sameRow"><input type="checkbox" id="sameChk"> <span>Ship to the billing address</span></label>
|
||
<fieldset id="fsShip"><legend>Shipping address</legend>${addrF("s")}</fieldset>
|
||
<h4>Duties and taxes</h4><div class="seg" data-k="duty"><button type="button" data-v="dap">Receiver pays (DAP)</button><button type="button" data-v="ddp">Prepaid (DDP)</button></div>
|
||
<label class="chk"><input type="checkbox" id="splitChk"> <span><b>Split shipping:</b> send part of the run to a second address</span></label>
|
||
<fieldset id="fsSplit"><legend>Second address</legend><div class="row"><span class="lbl">Copies to it</span><input type="number" id="splitQty" min="1" max="4999" style="width:90px"></div><div class="seg" data-k="ship2"><button type="button" data-v="th">Thailand</button><button type="button" data-v="intl">International</button></div>${addrF("x")}</fieldset>
|
||
<p class="hint">Personal data stays in this browser in the demo; it is never put into a share link.</p>`;
|
||
H[13]=`<div class="expl">Your tracks per side, the speed of each side and how the cut is checked. The demo is seeded with eight tracks; drop your own WAV, FLAC or AIFF files to replace them (read in this browser, nothing is uploaded).</div>
|
||
<div class="row"><label class="lbl" for="genre">Genre</label><select id="genre">${GENRES.map(g=>`<option value="${g[0]}">${esc(g[1])}</option>`).join("")}</select></div><p class="hint" id="genreChk"></p>
|
||
<div id="sidesBox"></div>
|
||
<h4>Gaps between tracks</h4><div class="seg" data-k="gaps"><button type="button" data-v="files">In my files</button><button type="button" data-v="add">Add 2 s</button><button type="button" data-v="none">None, a mix</button></div><p class="hint" id="gapHint"></p>
|
||
<h4>Lead-out message</h4><input type="text" id="leadout" maxlength="60" class="mono" style="width:100%" placeholder="e.g. THANK YOU CHIANG MAI" spellcheck="false" autocomplete="off"><div class="cnt" id="loCnt"></div><p class="hint" id="loHint"></p>
|
||
<h4>Tracklist</h4><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><small>Drag ⋮⋮ or use the arrows to reorder</small></div>
|
||
<label class="drop" id="dropAudio"><input type="file" id="fileAudio" accept=".wav,.flac,.aif,.aiff,.aifc,audio/*" multiple hidden><span class="di">⬇</span><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>
|
||
<h4>Automatic checks</h4><ul class="cl" id="checks"></ul>`;
|
||
H[14]=`<div class="expl">Artwork for the jacket, the labels and a printed inner sleeve. Files are read in this browser and checked against the template for the format and spine you chose: size, spine, bleed, resolution, colour space, fonts. They can also follow after the order.</div>
|
||
<div class="tpl" id="tplRow"></div>
|
||
<label class="drop" id="dropJacket"><input type="file" id="fileJacket" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="di">⬇</span><span><b>Jacket</b>: the spread from the template (back, spine, front) as PDF, AI or a 300 dpi image.</span></label>
|
||
<label class="drop" id="dropLabel"><input type="file" id="fileLabel" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="di">⬇</span><span><b>Labels</b>: PDF or AI from the template, a page per side, or a 300 dpi square image for side A.</span></label>
|
||
<label class="drop" id="dropInner"><input type="file" id="fileInner" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="di">⬇</span><span><b>Inner sleeve</b>: the sleeve front, square, 300 dpi.</span></label>
|
||
<div id="artRes"></div>
|
||
<p class="hint">PDF previews are drawn with pdf.js. Template sizes follow published plant templates until the packaging partner's dielines replace them.</p>`;
|
||
H[15]=`<div class="expl"><b>Pay the plant</b> for the run yourself, or <b>let your fans fund it</b>: they pre-order into a separate account and are refunded automatically if the goal is missed.</div>
|
||
<div class="tiles c2 route" data-k="route">${tile("direct",icon("plant",32)+"<b>Pay the plant directly</b><small>A deposit to start, the rest before dispatch</small>"+ptag("rt_direct"))}${tile("fans",icon("fans",32)+"<b>Let fans fund it</b><small>Fans pre-order; refunded if the goal is missed</small>"+ptag("rt_fans"))}</div>
|
||
<fieldset class="camp" id="fsCamp">
|
||
<h4>Your price to fans, per copy</h4>
|
||
<div class="fprow"><div class="stepper fp"><button type="button" data-act="fp" data-d="-1" aria-label="Lower fan price">−</button><input type="number" id="fanPrice" aria-label="Fan price"><small id="fpCur">THB</small><button type="button" data-act="fp" data-d="1" aria-label="Higher fan price">+</button></div><input type="range" id="fanR" aria-label="Fan price"></div>
|
||
<div class="try" id="fpChips"></div>
|
||
<div class="need" id="need"></div><p class="hint" id="needHint"></p>
|
||
<div id="fundKpi"></div>
|
||
<h4>Campaign</h4>
|
||
<div class="camprow"><div class="fld"><span>Campaign length</span><div class="rngrow"><input type="range" id="days" min="${R.campDays.v.min}" max="${R.campDays.v.max}" step="1" aria-label="Campaign length in days"><b class="daysv" id="daysV"></b></div></div>
|
||
<div class="fld"><span>Copies for yourself</span><div class="rngrow"><div class="stepper"><button type="button" data-act="acop" data-d="-1" aria-label="Fewer copies for yourself">−</button><input type="number" id="acop" min="0" max="200" step="5" aria-label="Copies for yourself"><small>pcs</small><button type="button" data-act="acop" data-d="1" aria-label="More copies for yourself">+</button></div></div></div></div>
|
||
<div class="limrow"><label class="chk"><input type="checkbox" id="limT"> <span><b>Limited edition</b></span></label><span class="capf" id="capF">of <input type="number" id="cap" min="1" max="5000" step="50" aria-label="Edition size"> numbered copies</span></div>
|
||
<label class="chk"><input type="checkbox" id="vatT"> <span>Show the numbers with ${R.vatPct.v} % VAT taken out of the fan price</span></label>
|
||
<div id="fundEcon"></div>
|
||
<h4>Your fans' money is safe</h4>
|
||
<div class="safe"><div><span class="si">${icon("vault",17)}</span><div><b>Held apart</b><small>Pre-orders sit in a separate account and are never used to run the plant until the campaign succeeds.</small></div></div><div><span class="si">${icon("refund",17)}</span><div><b>Refunded automatically</b><small>If the goal is missed, every fan gets their money back. Nobody ends up with a garage full of records.</small></div></div><div><span class="si">${icon("card",17)}</span><div><b>Pay the way fans pay</b><small>PromptPay QR, cards and wallets. Card details never touch the plant's servers.</small></div></div></div>
|
||
<div class="launch"><button type="button" class="btn" data-act="launch" id="bLaunch">Launch the campaign →</button><p class="hint" id="launchMsg"></p></div>
|
||
</fieldset>`;
|
||
H[16]=`<div class="rv"><div id="rvConf"></div><div id="rvMiss"></div><div id="rvSoft"></div>
|
||
<div class="rvgrid"><div class="sumcard"><div class="sumhd"><span id="rvDisc"></span><div><h3>Order summary</h3><div class="chips" id="rvChips"></div></div></div><div class="tscroll"><table id="rvTab"></table></div><p class="hint">Excludes 7 % VAT. <span class="pass">↗ pass-through</span> lines are the partner's price plus the handling margin shown. Badges give each rate's basis; hover for the source.</p></div>
|
||
<div><div class="sumcard"><h4 style="margin-top:0">When it ships</h4><div id="rvLead"></div></div>
|
||
<div class="sumcard" style="margin-top:14px"><h4 style="margin-top:0">Catalogue number</h4><input type="text" id="catno2" maxlength="15" class="mono" style="width:100%;text-transform:uppercase" spellcheck="false" autocomplete="off" aria-label="Catalogue number"><p class="hint">Your reference for this release, scribed in the dead wax: A to Z, 0 to 9 and hyphen, up to 15. Needed to order, not for a quote.</p>
|
||
<h4>Notes for the plant</h4><textarea id="notes" rows="3" style="width:100%" placeholder="Anything we should know: deadlines, a release date, a pressing to match"></textarea>
|
||
<h4>How you pay</h4><div id="rvPay"></div></div></div></div>
|
||
<details class="pl"><summary>How we price: price list ${PLV} and where every number comes from</summary><div id="plBox"></div></details>
|
||
<p class="foot" style="margin:18px 0 6px">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</p></div>`;
|
||
return H.map((h,i)=>i?`<div class="stp" data-step="${i}" hidden>${h}</div>`:"").join("");
|
||
}
|
||
|
||
/* ---- keys: allowed values, why an option is disabled ---- */
|
||
const ENUM={otype:["new","repress","tponly","print"],size:["12","10","7"],weight:[140,180],dinked:[false,true],labels:["printed","white","black"],
|
||
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"],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){
|
||
case "otype":return v==="tponly"||v==="print"?"Not offered at launch":"";
|
||
case "weight":return v===180&&S.size!=="12"?"Only 12\" records come in 180 g":"";
|
||
case "dinked":return S.size!=="7"?"Only 7\" singles come with a large centre hole":"";
|
||
case "jk":if(v==="gatefold"&&S.size!=="12")return "Gatefold jackets are for 12\" records only";
|
||
if(v==="printed3"&&S.discs>1&&S.size!=="7")return "Two or more records need the 5 mm spine or a gatefold";
|
||
if(v==="printed5"&&S.size==="7")return "7\" sleeves come with a 3 mm spine";return "";
|
||
case "finish":return finishOn()?"":S.jacket==="none"?"You supply the jacket: no finish from us":"Only a printed jacket takes a finish";
|
||
case "cleaning":return colourOn()?"":"Black vinyl needs no cleaning";
|
||
case "source":return rep()?REPW:"";
|
||
case "master":if(rep())return v==="partner"?"A repress uses the archived metal: there is nothing to master":REPW;if(lq())return "Your own lacquers are already cut: no mastering is possible here";return "";
|
||
case "cutStyle":return rep()?REPW:lq()?"Your studio cuts the lacquer":"";
|
||
case "gaps":return rep()?REPW:lq()?"Set by your cutting studio":"";
|
||
case "duty":if(S.ship!=="intl"&&!(S.split&&S.ship2==="intl"))return "Only for international delivery";return v==="ddp"?"Not offered at launch":"";
|
||
case "ship2":return S.split?"":"Turn on split shipping first";
|
||
case "discs":return v<1||v>4?"From 1 to 4 discs":"";
|
||
}
|
||
return "";
|
||
}
|
||
function getK(k){if(k==="jk")return S.jacket==="printed"?(thickOn()?"printed5":"printed3"):S.jacket;if(k==="innerT")return S.inner==="printed"?"printed":S.inner+"-"+S.innerCol;return S[k];}
|
||
const typed=(g,v)=>g.hasAttribute("data-num")?Number(v):g.hasAttribute("data-bool")?v==="true":v;
|
||
function normalize(){
|
||
if(S.size!=="12"){if(S.jacket==="gatefold")S.jacket="printed";if(S.weight===180)S.weight=140;}
|
||
if(S.size!=="7")S.dinked=false;
|
||
if(S.otype==="tponly"||S.otype==="print")S.otype="new";
|
||
S.dl.forEach(d=>{if(!FX[d.fx]||FX[d.fx].off)d.fx="solid";});
|
||
S.vd=Math.min(S.vd,S.discs-1);S.loud=S.cutStyle==="loud";
|
||
const sn=sideNames();Object.keys(S.stype).forEach(k=>{if(!sn.includes(k))delete S.stype[k];});Object.keys(S.srpm).forEach(k=>{if(!sn.includes(k))delete S.srpm[k];});
|
||
if(!audioSides().length)S.stype={};
|
||
if(S.ship!=="intl"&&!(S.split&&S.ship2==="intl"))S.duty="dap";
|
||
const as=audioSides();if(S.tracks.some(t=>!as.includes(t.side)))autoSplit();
|
||
sortTracks();
|
||
}
|
||
function setK(k,v){
|
||
if(ENUM[k]&&!ENUM[k].some(x=>x===v))return false;
|
||
if(why(k,v))return false;
|
||
if(k==="jk"){if(v==="printed3"){S.jacket="printed";S.spine="thin";}else if(v==="printed5"){S.jacket="printed";S.spine="thick";}else{S.jacket=v;if(v==="plain")S.spine="thin";}}
|
||
else if(k==="innerT"){if(v==="printed")S.inner="printed";else{const p=v.split("-");S.inner=p[0];S.innerCol=p[1];}}
|
||
else if(k==="view"){S.view=v;S.viewMan=true;}
|
||
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();
|
||
renderAll();return true;
|
||
}
|
||
function setSide(sk,side,v){
|
||
if(!sideNames().includes(side)||rep())return false;
|
||
if(sk==="stype"){if(v!=="audio"&&v!=="blank")return false;if(v==="blank"&&audioSides().filter(n=>n!==side).length===0){toast("At least one side needs audio");return false;}S.stype[side]=v;autoSplit();sortTracks();renderTracks();}
|
||
else if(sk==="srpm"){if(v!=="33"&&v!=="45")return false;sideNames().forEach(n=>{S.srpm[n]=rpmOf(n);});S.srpm[side]=v;S.rpm=S.srpm.A;}
|
||
renderAll();return true;
|
||
}
|
||
|
||
/* ---- price tags on the tiles ---- */
|
||
const perD=v=>plus(v)+" / disc",perC=v=>plus(v)+" / copy";
|
||
const PT={
|
||
rep:()=>money(R.stamper.v)+" / side for new stampers",
|
||
sz12:()=>money(R.press.v[12])+" / disc",sz10:()=>money(R.press.v[10])+" / disc",sz7:()=>money(R.press.v[7])+" / disc",
|
||
w140:()=>"included",w180:()=>perD(R.g180.v),
|
||
lab_printed:()=>"included",lab_white:()=>perD(R.plainLabel.v),lab_black:()=>perD(R.plainLabel.v),
|
||
"in_paper-white":()=>"included","in_paper-black":()=>"included","in_poly-white":()=>perD(R.inner.v.poly[S.size]-R.inner.v.paper[S.size]),"in_poly-black":()=>perD(R.inner.v.poly[S.size]-R.inner.v.paper[S.size]),in_printed:()=>perD(R.innerPrint.v[S.size]-R.inner.v.paper[S.size]),
|
||
jk_none:()=>"no jacket cost",jk_plain:()=>money(Math.round(R.jacket.v.plain[S.size][tierIdx(S.qty)]*(1+R.handling.v)))+" / copy",
|
||
jk_printed3:()=>money(Math.round(R.jacket.v.printed[S.size][tierIdx(S.qty)]*(1+R.handling.v)))+" / copy",
|
||
jk_printed5:()=>S.size==="7"?"":money(Math.round(R.jacket.v.printed[S.size][tierIdx(S.qty)]*(1+R.handling.v))+R.spine.v[S.size])+" / copy",
|
||
jk_gatefold:()=>money(Math.round(R.jacket.v.gatefold[12][tierIdx(S.qty)]*(1+R.handling.v)))+" / copy",
|
||
fin_uncoated:()=>"included",fin_matt:()=>perC(R.finish.v.matt[S.jacket==="gatefold"?"gf":S.size]),fin_gloss:()=>perC(R.finish.v.gloss[S.jacket==="gatefold"?"gf":S.size]),fin_spotuv:()=>perC(R.finish.v.spotuv[S.jacket==="gatefold"?"gf":S.size]),
|
||
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.",
|
||
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));
|
||
|
||
/* ---- sync controls with the state ---- */
|
||
const dis=(sel,w)=>{const e=$(sel);if(!e)return;e.disabled=!!w;e.title=w||"";};
|
||
function sync(){
|
||
$$(".tiles[data-k], .seg[data-k]").forEach(g=>{const k=g.dataset.k,cur=String(getK(k));
|
||
g.querySelectorAll("button[data-v]").forEach(b=>{const v=typed(g,b.dataset.v),w=why(k,v);
|
||
b.classList.toggle("on",String(v)===cur&&!(k==="finish"&&!finishOn())&&!(k==="cleaning"&&!colourOn())&&!(k==="master"&&(lq()||rep()))&&!(k==="cutStyle"&&(lq()||rep()))&&!(k==="dinked"&&S.size!=="7")&&!(k==="duty"&&w&&v!=="dap"));
|
||
b.disabled=!!w;b.title=w||b.dataset.tt||"";});});
|
||
$$("[data-pt]").forEach(e=>{const f=PT[e.dataset.pt];e.textContent=f?f():"";});
|
||
$$(".xopt").forEach(b=>b.classList.toggle("on",!!S[b.dataset.b]));
|
||
const s7=S.size==="7";["printed3","printed5"].forEach(v=>{const e=$("#jkt_"+v);if(e)e.textContent=(s7?"Picture sleeve":"Printed")+", "+(v==="printed3"?"3":"5")+" mm spine";});
|
||
$("#otNote").innerHTML=rep()?"<b>Repress:</b> the plant keeps the father and the mother of every side (production timeline), so a repress skips mastering, cutting and the father: new stampers are grown from the archived mother. No master, lacquer or cut is charged; quoted <b>2 to 3 weeks</b>.":"<b>New release:</b> mastered (or prepared) for vinyl, cut on the plant's lathe, plated in-house (lacquer, father, mother, stampers) and pressed. Father and mother are archived for represses.";
|
||
$("#dinkHint").textContent=s7?(S.dinked?"Dinked: the large jukebox hole, 38.2 mm.":"Small hole: plays on any turntable without an adapter."):"Only 7\" singles come with a large centre hole.";
|
||
$("#discsV").textContent=S.discs;$("#dMinus").disabled=S.discs<=1;$("#dPlus").disabled=S.discs>=4;
|
||
$("#discPic").innerHTML=`<svg width="${70+S.discs*14}" height="70" viewBox="0 0 ${70+S.discs*14} 70" aria-hidden="true">${discsOn().map((d,i)=>`<g transform="translate(${(S.discs-1-i)*14} 0)">${miniDisc(d,70,{label:"printed"})}</g>`).join("")}</svg>`;
|
||
$("#discsHint").innerHTML=`<b>${S.discs} record${S.discs>1?"s":""}</b>, sides ${sideNames().join(", ")}. `+(S.discs>1?(S.jacket==="gatefold"?"In a gatefold with a "+spineMM()+" mm spine.":S.jacket==="none"?"In your own jacket.":"The jacket gets the 5 mm spine; a gatefold is the other choice (step 7)."):"Fits a 3 mm spine.");
|
||
const dt=$("#dTabs");dt.innerHTML=S.discs>1?discsOn().map((d,i)=>`<button type="button" class="dtab${i===S.vd?" on":""}" data-act="dtab" data-i="${i}">${miniDisc(d,34,{label:"printed"})}<span>Disc ${i+1} (${sideNames()[2*i]}${sideNames()[2*i+1]})</span></button>`).join("")+`<button type="button" class="chip" data-act="copyAll" title="Give every disc the colour of disc ${S.vd+1}">Copy disc ${S.vd+1} to all</button>`:"";
|
||
$("#dTabsW").hidden=S.discs<2;
|
||
renderFx();
|
||
const dx=dxOn();$("#pressB").className="pbadge"+(dx?" dx":"");$("#pressB").textContent=dx?"Runs on the Duplex system":"Runs on the Classic or the Duplex";
|
||
$("#cleanHint").textContent=!colourOn()?"Only for colour vinyl.":S.cleaning?"Purged before your colour; light colours can still show the odd speck. "+money(R.clean.v)+" per title.":"Some records can carry streaks of the previous colour.";
|
||
$("#spineHint").textContent=spineHint();
|
||
$("#finHint").textContent=finishOn()?(S.jacket==="gatefold"?"A gatefold is about twice the sheet, so the finish costs about twice as much.":"Finish rates are estimates until the packaging partner quotes."):"Only a printed jacket takes a finish: choose one in step 7.";
|
||
$("#mHint").textContent=rep()?REPW+".":lq()?"Your own lacquers are already cut: no mastering is possible here.":S.master==="partner"?"Partner mastering replaces the in-house line; the vinyl prep for the lathe stays included. You approve the master at gate 2.":S.master==="none"?"Your master goes to the lathe as it is; the engineer only prepares it for cutting. No gate 2.":"Priced per side: "+money(R.master.v)+".";
|
||
$("#csHint").textContent=S.cutStyle==="loud"?"Loud cuts need shorter sides: the side limits drop to 60 % (Diggers Factory's dance-music figures).":S.cutStyle==="fidelity"?"A gentler level keeps the most dynamics. Side limits as listed in step 13.":"The default: level and dynamics balanced for most music.";
|
||
$("#cutRoom").innerHTML=rep()?"<b>Repress:</b> no cut. New stampers come from the mother archived with your first order.":lq()?"<b>Your own lacquers:</b> cut and scribed by your studio and sent by express courier the day they are cut; a lacquer has to be plated within days. Inspected under the microscope on arrival.":"Cut in-house on the plant's <b>Sillitoe SDMS</b> lathe system (launch plant, total investment) and plated in the same building: lacquer, father, mother and stampers. The run-out is scribed by hand in the lacquer.";
|
||
$("#tpWarn").hidden=S.tps>0;
|
||
const Q=quote(S.qty);$("#shipCalc").innerHTML=`About <b>${(wPer()).toFixed(2)} kg</b> a copy (estimate). `+Q.H.map(h=>esc(h.t)+(h.r?": "+h.n+" "+h.u+" × "+money(h.r)+" = <b>"+money(h.a)+"</b>":": <b>free</b>")).join("; ")+".";
|
||
$("#splitChk").checked=S.split;$("#fsSplit").disabled=!S.split;$("#fsSplit").title=S.split?"":"Turn on split shipping first";
|
||
const pick=S.ship==="pickup";$("#sameChk").checked=S.same;$("#sameChk").disabled=pick;$("#sameRow").classList.toggle("off",pick);$("#sameRow").title=pick?"Pick-up: no delivery address needed":"";
|
||
$("#fsShip").disabled=pick||S.same;$("#fsShip").title=pick?"Pick-up: no delivery address needed":S.same?"Ships to the billing address":"";
|
||
const lw=rep()?"The run-out is already on the archived metal":lq()?"Your cutting studio scribes your lacquer":"";dis("#leadout",lw);
|
||
$("#loCnt").textContent=S.leadout.length+" / 60";
|
||
$("#loHint").textContent=lw||"Scribed by hand in the lacquer's dead wax by the cutting engineer: lacquers take a cold scribe only, never a laser, and records are never lasered (quality automation). It is pressed into every copy; letters, digits and simple punctuation.";
|
||
$("#gapHint").textContent=S.gaps==="add"?"We add 2 s of silence between tracks; it counts towards the side length.":S.gaps==="none"?"A continuous side: no visible bands between tracks.":"The gaps in your files are cut as they are; each shows as a band on the record.";
|
||
const G=GENRES.find(g=>g[0]===S.genre)||GENRES[0];$("#genreChk").innerHTML=G[2]==="bass"?(S.cutStyle==="loud"?"Bass-heavy music with the Loud style: keep sides short (limits at 60 %).":"Bass-heavy music: consider the <b>Loud</b> cutting style (step 10) and 45 rpm where a side allows."):G[2]==="dyn"?"Dynamic music: the <b>Fidelity</b> style keeps the most headroom.":"The level check below looks at each side's speed for this genre.";
|
||
const aw=rep()?"A repress plays the archived master":"";$("#dropAudio").classList.toggle("off",!!aw);$("#fileAudio").disabled=!!aw;$("#dropAudio").title=aw;
|
||
[["jacket","#dropJacket","#fileJacket"],["label","#dropLabel","#fileLabel"],["inner","#dropInner","#fileInner"]].forEach(([k,d,f])=>{const w=tplOff(k);$(d).classList.toggle("off",!!w);$(f).disabled=!!w;$(d).title=w;});
|
||
$("#shipFan").hidden=!fansOn();
|
||
}
|
||
function renderFx(){
|
||
const d=S.dl[Math.min(S.vd,S.discs-1)],open=S.fxOpen===undefined?d.fx:S.fxOpen;
|
||
$("#fxList").innerHTML=FXO.map(fx=>{const F=FX[fx];
|
||
if(F.off)return `<div class="fx"><button type="button" class="fxh" data-act="fx" data-fx="${fx}" disabled title="${F.off}"><svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="14" fill="#2a2a2a"/>${[[9,10],[19,8],[14,18],[21,19],[8,20],[16,12]].map(([x,y])=>`<circle cx="${x}" cy="${y}" r="1.3" fill="#e9d48a"/>`).join("")}</svg><b>${F.t}</b><span class="pbadge">Duplex</span><span class="sp"></span><span class="pr" style="color:var(--mute)">${F.off}</span></button></div>`;
|
||
const pv={fx,c:d.c},on=d.fx===fx,op=open===fx,r=fx==="solid"&&d.c.solid[0]==="black"?0:fxRate(pv);
|
||
const slots=F.slots.map(([nm,pal],si)=>{const cur=d.c[fx][si];return `<div class="slot"><span>${nm}: <i>${esc(cname(cur))}</i></span><div class="sws">${PAL[pal].map(c=>`<button type="button" class="sw${trOf(c)?" tr":""}${c==="none"?" none":""}${cur===c?" on":""}"${c==="none"?"":` style="background-color:${hexOf(c)}"`} data-act="sw" data-fx="${fx}" data-slot="${si}" data-c="${c}" title="${esc(cname(c)===("none")?"No third colour":cname(c))}" aria-label="${esc(nm+": "+cname(c))}"></button>`).join("")}</div></div>`;}).join("");
|
||
return `<div class="fx${on?" on":""}"><button type="button" class="fxh" data-act="fx" data-fx="${fx}" aria-expanded="${op}" title="${on?"Your choice for disc "+(S.vd+1):"Use "+F.t.toLowerCase()+" for disc "+(S.vd+1)}">${miniDisc(pv,30,{label:"none",seed:5})}<b>${F.t}</b><span class="pbadge${F.dx?" dx":""}">${F.dx?"Duplex":"Classic or Duplex"}</span><span class="sp"></span><span class="pr">${r?perD(r):"included"}</span><span class="car">${op?"▾":"▸"}</span></button>${op?`<div class="fxb">${slots}</div>`:""}</div>`;}).join("");
|
||
}
|
||
|
||
/* ---- rail, panel head, quote card ---- */
|
||
function summ(n){
|
||
switch(STEPS[n-1][0]){
|
||
case "type":return rep()?"Repress":"New release";
|
||
case "size":return S.size+"\""+(dinkOn()?", dinked":"");
|
||
case "discs":return S.discs+" disc"+(S.discs>1?"s":"");
|
||
case "colour":return colSum()+(S.size==="12"?", "+S.weight+" g":"");
|
||
case "labels":return {printed:"Printed",white:"Plain white",black:"Plain black"}[S.labels];
|
||
case "inner":return S.inner==="printed"?"Printed":(S.inner==="poly"?"Poly-lined, ":"Paper, ")+S.innerCol;
|
||
case "jacket":return S.jacket==="none"?"Supplied by you":S.jacket==="plain"?"Plain, "+spineMM()+" mm spine":S.jacket==="gatefold"?"Gatefold":(S.size==="7"?"Picture sleeve":"Printed")+", "+spineMM()+" mm spine";
|
||
case "finish":return finishOn()?FINN[S.finish][0].toUpperCase()+FINN[S.finish].slice(1):"No printed jacket";
|
||
case "extras":{const x=["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag"].filter(k=>S[k]);return x.length?(x.length===1?R[x[0]].t:x.length+" extras"):"None";}
|
||
case "cut":return rep()?"From the archived metal":lq()?"Your own lacquers":({digital:"Digital",tape:"Tape"}[S.source])+", "+({none:"no mastering",inhouse:"in-house mastering",partner:"partner mastering"}[S.master])+", "+S.cutStyle;
|
||
case "tp":return S.tps?S.tps+" copies":"None";
|
||
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[RV];
|
||
$("#rail").innerHTML=STEPS.map(([id],i)=>{const n=i+1,t=stT(n),on=n===S.step,vis=!!S.visited[n],miss=rv&&M.some(m=>m.s===n);
|
||
return `<button type="button" class="ri${on?" on":""}${vis?" vis":""}${vis&&!on&&!miss?" done":""}${miss?" miss":""}" data-act="go" data-s="${n}" title="${esc(t+": "+summ(n))}"><span class="rn">${n}</span><span class="ric">${icon(id,18)}</span><span class="rt"><b>${esc(t)}</b><small>${esc(summ(n))}</small></span><span class="rch">›</span></button>`;}).join("");
|
||
const on=$("#rail .ri.on"),c=$("#rail");
|
||
if(on&&c&&c.scrollWidth>c.clientWidth+4){const o=on.offsetLeft-c.offsetLeft;if(o<c.scrollLeft||o+on.offsetWidth>c.scrollLeft+c.clientWidth)c.scrollLeft=Math.max(0,o-30);}
|
||
}
|
||
function renderHead(){
|
||
const n=S.step,app=$("#app");
|
||
$("#pNum").textContent=n;$("#pIcon").innerHTML=icon(STEPS[n-1][0],20);$("#pTitle").textContent=stT(n);
|
||
$$(".stp").forEach(x=>x.hidden=+x.dataset.step!==n);
|
||
app.classList.toggle("wide",n===RV);app.classList.toggle("closed",S.closed&&n!==RV);
|
||
$("#reopenB").hidden=!(S.closed&&n!==RV);
|
||
$("#pFoot").hidden=n===RV;$("#rvBar").hidden=n!==RV;
|
||
$("#nextB").innerHTML=n<RV?"Next: "+esc(stT(n+1))+" →":"";
|
||
}
|
||
const QTYW="Fan-funded: the fans decide how many are pressed; the goal follows from the fan price (step "+PAY+")";
|
||
function renderCard(){
|
||
const Q=quote(S.qty),sw=shipWindow(),L=leadInfo(false),M=missing(),F=fansOn(),E=F?econ():null,G=E&&E.goal?E.at(E.goal):null,cw=F?campWhy(E):"";
|
||
if(F){
|
||
$("#qL1").textContent="Fan price";$("#qTot").textContent=money(S.fanPrice);
|
||
$("#qPer").textContent="per copy, plus shipping";
|
||
$("#qL2").textContent="Fans needed";$("#qShip").textContent=E.goal?fmtInt(E.goal)+" fans":"Price too low";
|
||
$("#qShipN").textContent=E.goal?money(E.goal*S.fanPrice)+" in pre-orders in "+S.days+" days"+(capOn()?", edition of "+fmtInt(capOn()):""):"The fan price does not cover the cost per copy";
|
||
$("#qKeepW").hidden=false;
|
||
$("#qKeep").innerHTML=G?`<div class="ql g">You keep at the goal</div><div class="qd">${money(G.per)} a copy</div><div class="qs">${money(G.keep)} in all, after the ${money(E.tSum)} one-off setup, pressing and fees</div>`:`<div class="ql g">You keep at the goal</div><div class="qd">${money(0)}</div><div class="qs">Raise the fan price in step ${PAY}.</div>`;
|
||
$("#qNote").textContent=cw||"The run is the fans' pre-orders plus your own copies.";$("#qNote").classList.toggle("info",!cw);
|
||
}else{
|
||
$("#qL1").textContent="Estimated price";$("#qTot").textContent=money(Q.total);
|
||
$("#qPer").textContent=money(Q.per)+" per copy"+(Q.hSum?", plus shipping":"")+", excl. VAT";
|
||
$("#qL2").textContent="Estimated ship date";$("#qShip").textContent=dRange(sw[0],sw[1]);
|
||
$("#qShipN").textContent=L.repress?"Repress, quoted 2 to 3 weeks":"If ordered today; "+L.qw[0]+" to "+L.qw[1]+" weeks quoted from your test-pressing approval";
|
||
$("#qKeepW").hidden=true;
|
||
$("#qNote").textContent=S.qty<R.minRun.v?"Below the "+R.minRun.v+"-copy minimum run: a quote only. Small runs cost more per copy.":"";$("#qNote").classList.remove("info");
|
||
}
|
||
$("#qcard").classList.toggle("min",S.qmin);
|
||
let sv=null;try{sv=JSON.parse(localStorage.getItem("cnx-order-saved")||"null");}catch(e){}
|
||
$("#qSaved").innerHTML=sv&&sv.h?`Saved ${new Date(sv.at).toLocaleString("en-GB",{day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"})} · <button type="button" class="linkb" data-act="restore">Restore</button>`:"";
|
||
/* 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 ---- */
|
||
function sideMsgs(s,G){
|
||
const out=[];if(s.type!=="audio")return out;
|
||
if(S.size==="7"&&s.rpm==="33")out.push(["warn","33⅓ on a 7\" is not recommended: the side ends close to the label, which costs level and adds inner-groove distortion. 45 is the norm."]);
|
||
const di=Math.floor(sideNames().indexOf(s.n)/2),mate=sideNames()[2*di+(sideNames().indexOf(s.n)%2?0:1)];
|
||
if(mate&&stypeOf(mate)==="audio"&&rpmOf(mate)!==s.rpm)out.push(["warn","Side "+mate+" plays at "+rpmT(rpmOf(mate))+": two speeds on one disc are fine, and both go on the label."]);
|
||
if(G[2]==="bass"&&s.rpm==="33"){const l45=LIM[S.size]["45"][1]*60*(S.loud?LOUD:1);
|
||
out.push(["warn",s.sec<=l45?"Consider 45 rpm: "+G[1].split(",")[0].toLowerCase()+" cuts louder with more bass at 45, and this side fits (at most "+mmss(l45)+" at 45).":"Consider 45 rpm for level, but this side is too long for 45 (at most "+mmss(l45)+"); at 33⅓ expect a quieter cut."]);}
|
||
else if(!(S.size==="7"&&s.rpm==="33"))out.push(["ok",(s.rpm==="45"?"45":"33⅓")+" looks fine for "+G[1].split(",")[0].toLowerCase()+"."]);
|
||
return out;
|
||
}
|
||
function renderSides(){
|
||
const ss=sideStats(),G=GENRES.find(g=>g[0]===S.genre)||GENRES[0],rw=rep()?` disabled title="${esc("Set by the archived title")}"`:"";
|
||
$("#sidesBox").innerHTML=ss.map(s=>`<div class="sidec"><div class="row"><span class="sn">Side ${s.n}</span><span style="display:flex;gap:6px;flex-wrap:wrap"><div class="seg cmp" data-sk="stype" data-side="${s.n}">${[["audio","Audio"],["blank","Blank"],["etched","Etched"]].map(([v,t])=>`<button type="button" data-v="${v}" class="${s.type===v?"on":""}"${v==="etched"?' disabled title="Not offered at launch"':rw||(v==="blank"?' title="A smooth side with a label, no grooves"':"")}>${t}</button>`).join("")}</div><div class="seg cmp" data-sk="srpm" data-side="${s.n}">${["33","45"].map(r=>`<button type="button" data-v="${r}" class="${s.rpm===r&&s.type==="audio"?"on":""}"${s.type!=="audio"?' disabled title="A blank side is not cut"':rw}>${r==="33"?"33⅓":"45"}</button>`).join("")}</div></span></div>`+
|
||
(s.type==="audio"?`<div class="row"><small>${s.count} track${s.count===1?"":"s"}${S.gaps==="add"&&s.count>1?", gaps included":""} · comfortable ${mmss(s.lim[0])}, at most ${mmss(s.lim[1])}</small><span class="st ${s.st}">${mmss(s.sec)}</span></div><div class="bar"><i class="${s.st}" style="width:${Math.min(100,s.sec/s.lim[1]*100).toFixed(1)}%"></i><b style="left:${(s.lim[0]/s.lim[1]*100).toFixed(1)}%"></b></div>`:`<small>Blank side: smooth vinyl with the label and no grooves. It still needs its own stamper.</small>`)+
|
||
sideMsgs(s,G).map(([c,t])=>`<p class="msg ${c}">${c==="ok"?"✓ ":"! "}${t}</p>`).join("")+`</div>`).join("")+`<p class="hint">Comfortable: lower end of microcut.studio's format guide; at most: the team's table (artist platform). ${S.loud?"Loud style: limits times 0.6. ":""}The engineer check decides, not this table.</p>`;
|
||
}
|
||
function fileInfo(t){
|
||
if(!t.file)return "";
|
||
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(){
|
||
sortTracks();const names=audioSides(),ss=sideStats(),n=S.tracks.length;let rows="",cur=null,acc=0;
|
||
S.tracks.forEach((t,i)=>{
|
||
if(t.side!==cur){cur=t.side;acc=0;const s=ss.find(x=>x.n===cur);rows+=`<tr class="sidehd"><td colspan="8">Side ${cur} · ${rpmT(rpmOf(cur))} · ${mmss(s?s.sec:0)}</td></tr>`;}
|
||
const st=acc,en=acc+(t.sec||0);acc=en+(S.gaps==="add"?2:0);
|
||
rows+=`<tr data-id="${t.id}"><td class="drag" draggable="true" title="Drag to reorder">⋮⋮</td><td>${i+1}</td><td><input type="text" class="tt" value="${esc(t.title)}" aria-label="Track title">${fileInfo(t)}</td><td class="num"><small>${mmss(st)}<br>${mmss(en)}</small></td><td><input type="text" class="tdur" value="${t.sec?mmss(t.sec):""}" placeholder="m:ss" aria-label="Length"${t.file&&t.info&&t.info.sec?' readonly title="Read from the file"':""}></td><td><select class="tside" aria-label="Side">${names.map(x=>`<option${x===t.side?" selected":""}>${x}</option>`).join("")}</select></td><td style="white-space:nowrap"><button type="button" class="ib" data-act="up" title="Move up"${i?"":" disabled"}>▲</button> <button type="button" class="ib" data-act="down" title="Move down"${i<n-1?"":" disabled"}>▼</button></td><td><button type="button" class="x" data-act="rm" title="Remove">×</button></td></tr>`;});
|
||
$("#trk").innerHTML=`<thead><tr><th></th><th>#</th><th>Title</th><th class="num">Start<br>end</th><th>Length</th><th>Side</th><th></th><th></th></tr></thead><tbody>${rows||`<tr><td colspan="8"><small>No tracks: add one or drop your files.</small></td></tr>`}</tbody>`;
|
||
}
|
||
function renderChecks(){
|
||
const ic={ok:"✓",warn:"!",bad:"✕",pend:"○"};
|
||
$("#checks").innerHTML=checkList().map(([s,t,d])=>`<li><span class="st ${s}">${ic[s]}</span><div><b>${t}</b><small>${d}</small></div></li>`).join("");
|
||
}
|
||
function renderArt(){
|
||
const ic={ok:"✓",warn:"!",bad:"✕",pend:"○"};
|
||
$("#tplRow").innerHTML=["jacket","label","inner"].map(k=>{const off=tplOff(k)||(k==="inner"?"The inner sleeve template comes with the packaging partner's dielines":"");
|
||
return `<div><button type="button" class="btn sec sm" data-act="tpl" data-k="${k}"${off?` disabled title="${esc(off)}"`:""}>⤓ ${k==="jacket"?(S.jacket==="gatefold"?"Gatefold":"Jacket"):k==="label"?"Label":"Inner sleeve"} template, PDF</button><small>${esc(tplOff(k)||tplDims(k).txt)}</small></div>`;}).join("");
|
||
$("#artRes").innerHTML=["jacket","label","inner"].map(k=>{const a=S.art[k];if(!a)return "";const cs=artChecks(k);
|
||
return `<div class="art">${artThumb(a)}<div><b>${ART[k].t}</b> <small>${esc(a.name)}</small>${tplOff(k)?` <small class="st warn">not used: ${esc(tplOff(k).toLowerCase())}</small>`:""}<ul>${cs.map(([s,t])=>`<li><span class="st ${s}">${ic[s]}</span> ${esc(t)}</li>`).join("")}${artNote(a)}</ul><button type="button" class="btn sec sm" data-act="artRm" data-k="${k}">Remove</button></div></div>`;}).join("");
|
||
}
|
||
|
||
/* ---- review ---- */
|
||
const AF={name:"name",email:"e-mail",phone:"phone",line1:"address line",city:"city",postcode:"postcode",country:"country"};
|
||
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(!fansOn()&&S.qty<R.minRun.v)M.push({s:12,t:"Copies: the minimum run for an order is "+R.minRun.v,f:"qtyShip"});
|
||
const ss=sideStats(),as=ss.filter(s=>s.type==="audio");
|
||
if(!as.length)M.push({s:13,t:"Tracklist: no side has audio"});
|
||
as.filter(s=>!s.count).forEach(s=>M.push({s:13,t:"Side "+s.n+": no tracks added"}));
|
||
as.filter(s=>s.st==="bad").forEach(s=>M.push({s:13,t:"Side "+s.n+": "+mmss(s.sec)+" is longer than a side holds ("+mmss(s.lim[1])+")"}));
|
||
if(!S.catno)M.push({s:9,t:"Catalogue number",f:"catno"});
|
||
const fl=(g,ff)=>ff.map(f=>["a_"+g+"_"+f,AF[f]]);
|
||
let m=addrMiss("b",["name","email","phone","line1","city","postcode","country"]);if(m.length)M.push({s:12,t:"Billing address",fs:fl("b",m)});
|
||
if(S.ship!=="pickup"&&!S.same){m=addrMiss("s",["name","phone","line1","city","postcode","country"]);if(m.length)M.push({s:12,t:"Shipping address",fs:fl("s",m)});}
|
||
if(S.split){m=addrMiss("x",["name","phone","line1","city","postcode","country"]);if(m.length)M.push({s:12,t:"Second address",fs:fl("x",m)});
|
||
if(!(S.splitQty>0&&S.splitQty<S.qty))M.push({s:12,t:"Split shipping: copies for the second address",f:"splitQty"});}
|
||
return M;
|
||
}
|
||
function soft(){
|
||
const L=[];
|
||
if(!rep()&&S.source==="digital"){const n=S.tracks.filter(t=>t.file).length;if(n<S.tracks.length)L.push("Master files: "+n+" of "+S.tracks.length+" tracks have a file; they follow before gate 1 (the master plan).");}
|
||
if(lq())L.push("Your lacquers: sent by express courier the day they are cut.");
|
||
["jacket","label","inner"].forEach(k=>{if(!tplOff(k)&&!S.art[k])L.push(ART[k].t+" artwork: checked against the template when it arrives.");});
|
||
L.push("Rights: you confirm at gate 1 that you own or have licensed the recordings and compositions.");
|
||
return L;
|
||
}
|
||
const badge=id=>{const r=R[id];return r?`<span class="badge b-${r.k}" title="${esc(r.src)}">${{doc:"doc",team:"team",est:"est."}[r.k]}</span>`:"";};
|
||
function orderNo(){let h=0;for(const c of stateHash())h=(h*31+c.charCodeAt(0))>>>0;return 1000+h%9000;}
|
||
function renderReview(){
|
||
const F=fansOn(),E=F?econ():null,G=E&&E.goal?E.at(E.goal):null,M=missing(),L=leadInfo(F),sw=shipWindow(),q=F?(E.goal||E.min)+S.artistCopies:S.qty,Q=quote(q),W=F?campWindow():null,cap=capOn();
|
||
$("#rvMiss").innerHTML=M.length?`<div class="missbox"><h3>⚠ Please complete the following before ${F?"launching":"ordering"}:</h3><ul>${M.map(m=>`<li><button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${m.f||(m.fs?m.fs[0][0]:"")}">${esc(m.t)}</button>${m.fs?": missing "+m.fs.map(([f,n])=>`<button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${f}">${n}</button>`).join(", "):""}</li>`).join("")}</ul></div>`
|
||
:`<div class="missbox ok"><h3>✓ Everything needed to ${F?"launch":"order"} is filled in.</h3><span class="hint">${F?(S.launched?"The campaign is running above (simulated).":"Launch the campaign with the button at the bottom."):"Place the order with the button at the bottom."}</span></div>`;
|
||
$("#rvSoft").innerHTML=`<div class="softbox"><b>Can follow after the ${F?"launch":"order"}</b><ul>${soft().map(x=>`<li>${esc(x)}</li>`).join("")}</ul></div>`;
|
||
$("#rvDisc").innerHTML=miniDisc(S.dl[0],92,{label:S.labels});
|
||
$("#rvChips").innerHTML=[rep()?"Repress":"New release","Vinyl",S.size+"\"",S.discs>1?S.discs+" discs":"1 disc"].concat(F?["Fan-funded"].concat(E.goal?["Goal: "+fmtInt(E.goal)+" fans"]:[]):["Qty: "+fmtInt(q)],[PLV]).map(c=>`<span>${esc(c)}</span>`).join("");
|
||
const tr=x=>`<tr><td>${esc(x.t)} ${badge(x.id)}${x.pass!=null?` <span class="pass">↗ partner ${money(x.pass)} + ${pctT(R.handling.v)}</span>`:""}<small class="qrm">${x.u?fmtInt(x.n)+" "+x.u+" × ":""}${money(x.r)}</small></td><td class="num qr">${x.u?fmtInt(x.n)+" "+x.u:""}</td><td class="num qr">${money(x.r)}</td><td class="num">${money(x.a)}</td></tr>`;
|
||
const head=`<tbody><tr class="hd"><th>One-off, for this release</th><th class="num qr">Qty</th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.T.map(tr).join("")}<tr class="sub"><td colspan="3">One-off total</td><td class="num">${money(Q.tSum)}</td></tr>
|
||
<tr class="hd"><th>Per copy × ${fmtInt(q)}${F?(E.goal?", the goal: "+fmtInt(E.goal)+" fans + "+fmtInt(S.artistCopies)+" for you":", the minimum run"):""}</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Per copy</th></tr>${Q.C.map(tr).join("")}<tr class="sub"><td colspan="3">${money(Q.cSum)} per copy × ${fmtInt(q)} copies</td><td class="num">${money(Q.cSum*q)}</td></tr>`;
|
||
$("#rvTab").innerHTML=F?head+`
|
||
<tr class="hd"><th>Shipping</th><th class="num qr"></th><th class="num qr"></th><th class="num"></th></tr><tr><td colspan="3">One tracked parcel per fan, paid by the fan at checkout, at cost</td><td class="num"><small>paid by fans</small></td></tr>
|
||
<tr class="tot"><td colspan="3">Cost of the run${E.goal?" at the goal":""}, excl. VAT</td><td class="num">${money(Q.tSum+q*Q.cSum)}</td></tr>`+(G?`<tr class="tot"><td colspan="3">Fan money after payment fees${S.vat?" and VAT":""}, ${fmtInt(E.goal)} × ${money(E.net1)} ${badge("fee")}</td><td class="num">${money(G.net)}</td></tr>
|
||
<tr class="tot"><td colspan="3">You keep at the goal</td><td class="num">${money(G.keep)}</td></tr><tr><td colspan="3">That is, a copy</td><td class="num">${money(G.per)}</td></tr>
|
||
<tr><td colspan="4"><small>Nothing is paid up front: the plant is paid from the fans' money when the campaign succeeds. More fans than the goal spread the one-off setup over more copies (step ${PAY}).</small></td></tr></tbody>`
|
||
:`<tr><td colspan="4"><div class="note bad" style="margin:4px 0">At ${money(S.fanPrice)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price in step ${PAY}.</div></td></tr></tbody>`)
|
||
:head+`
|
||
<tr class="hd"><th>Shipping</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.H.map(tr).join("")}
|
||
<tr class="tot"><td colspan="3">Order total, excl. VAT</td><td class="num">${money(Q.total)}</td></tr><tr class="tot"><td colspan="3">Per copy, before shipping</td><td class="num">${money(Q.per)}</td></tr>
|
||
<tr><td colspan="3">Deposit to start, ${pctT(R.deposit.v)} ${badge("deposit")}</td><td class="num">${money(Q.total*R.deposit.v)}</td></tr><tr><td colspan="3">Balance before dispatch</td><td class="num">${money(Q.total*(1-R.deposit.v))}</td></tr></tbody>`;
|
||
const closeT=(S.launched?"Closes ":"If launched today, closes ");
|
||
$("#rvLead").innerHTML=(F?(L.repress?`<div class="kv"><span>Campaign</span><b>${S.days} days</b><span>${closeT}</span><b>${fmtD(W.close)}</b><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>If funded, ships about</span><b>${dRange(W.ship[0],W.ship[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline); it starts when the campaign succeeds.</p>`
|
||
:`<div class="kv"><span>Campaign</span><b>${S.days} days</b><span>${closeT}</span><b>${fmtD(W.close)}</b><span>Then, before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>If funded, ships about</span><b>${dRange(W.ship[0],W.ship[1])}</b></div><p class="hint">Cutting starts only when the campaign succeeds, because a lacquer has to be plated within one to three days (artist platform). The quote counts from your last approval. Stage times: mature-plant column of the production timeline.</p>`)
|
||
:(L.repress?`<div class="kv"><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.</p>`
|
||
:`<div class="kv"><span>Before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">The quote counts from your last approval: the plant cannot be held to a clock that waits for approvals. ${L.printed?"A printed jacket is on the critical path; supplying your own saves 1 to 2 weeks.":"No printed jacket to wait for, which saves 1 to 2 weeks."} Stage times: mature-plant column of the production timeline.</p>`))+
|
||
`<details><summary class="hint">Stages${L.repress?"":" before your test-pressing approval"}</summary><table>${L.pre.map(s=>`<tr><td>${s.gate?"◆ ":""}${esc(s.t)}</td><td class="num">${s.d[0]} to ${s.d[1]} days</td></tr>`).join("")}</table></details>`;
|
||
$("#rvPay").innerHTML=F?`<div class="kv"><span>Fan-funded, fan price</span><b>${money(S.fanPrice)}</b><span>Fans needed</span><b>${E.goal?fmtInt(E.goal):"price too low"}</b><span>Your break-even</span><b>${E.be!=null?fmtInt(E.be)+" fans":"none"}</b><span>You keep at the goal</span><b>${G?money(G.per)+" a copy":money(0)}</b><span>Campaign</span><b>${S.days} days</b><span>Limited edition</span><b>${cap?fmtInt(cap)+" copies":"no"}</b><span>Your own copies</span><b>${fmtInt(S.artistCopies)}</b><span>Payment fees ${badge("fee")}</span><b>${pctT(R.fee.v)}</b></div><p class="hint">Set in step ${PAY}. <button type="button" class="linkb" data-act="go" data-s="${PAY}">Change the campaign or pay the plant directly</button></p>`
|
||
:`<div class="kv"><span>Direct order, deposit ${pctT(R.deposit.v)} ${badge("deposit")}</span><b>${money(Q.total*R.deposit.v)}</b><span>Balance before dispatch</span><b>${money(Q.total*(1-R.deposit.v))}</b></div><p class="hint">Or let your fans pre-order it: the record is pressed when enough of them have, and every fan is refunded if not. <button type="button" class="linkb" data-act="go" data-s="${PAY}">Fan funding in step ${PAY}</button></p>`;
|
||
if(F&&S.launched){const no=orderNo(),yr=S.launchDate.getFullYear(),nm=S.addr.b.name||"the artist";
|
||
$("#rvConf").innerHTML=`<div class="cgrid"><div class="sumcard"><div class="simhd"><h3>Your campaign <small class="hint">simulated</small></h3><div class="seg cmp" data-k="scen"><button type="button" data-v="sell">Sells out</button><button type="button" data-v="make">Just makes it</button><button type="button" data-v="miss">Falls short</button></div></div>
|
||
<p class="hint" style="margin:6px 0 0">Pick how it goes and press play: ${S.days} days in about 8 seconds, or drag the slider to any day.</p>
|
||
<div class="simrow"><button type="button" class="btn sec sm" data-act="play" id="playBtn"></button><input type="range" id="simDay" min="0" max="${S.days}" step="0.1" aria-label="Campaign day"><span class="hint" id="dayLbl"></span></div>
|
||
<div class="bar big"><i id="simBar"></i></div><div class="kpis k3" id="simKpis"></div><p id="simStatus" style="font-size:13.5px;margin:8px 0 0"></p><div id="simOut"></div></div>
|
||
<div class="conf"><h3>✓ Campaign launched (simulated): CNX-${yr}-${no}</h3><p>No page went live and nobody was charged. In the real platform the campaign is written to <b>ERPNext</b>, the plant's only system of record, through the plant's integration layer and its outbox; the public side never talks to ERPNext directly (artist platform, digital factory).</p>
|
||
<ol><li><b>Customer</b> <code>${esc(nm)}</code>: the artist, matched by e-mail or created, with the billing address.</li>
|
||
<li><b>Campaign</b> <code>CMP-${yr}-0${no}</code>, a custom document from launch: ${money(S.fanPrice)} a copy, goal ${fmtInt(E.goal||0)} fans, closes ${fmtD(W.close)}${cap?", limited to "+fmtInt(cap):""}.</li>
|
||
<li><b>Payment Entry</b> for each pledge: an advance into the ring-fenced account as it arrives, with the fan as a customer. A missed campaign's refunds are posted as payment entries too.</li>
|
||
<li>If it succeeds: a <b>Sales Order</b> the advances are allocated to, for the fans' copies${S.artistCopies?" plus your "+fmtInt(S.artistCopies):""}, and a <b>Delivery Note</b> for each fan's parcel.</li>
|
||
<li><b>Release</b> ${esc(S.catno)} with ${sideNames().length} <b>Side</b> records (custom document types in the <code>cnx_erp</code> app) and three <b>Artist Gate</b> records: master plan, master, test pressing.</li></ol>
|
||
<p><button type="button" class="btn sec" data-act="unlaunch">Back to the campaign settings</button></p></div></div>`;
|
||
updateSim();}
|
||
else if(!F&&S.placed){const P=S.placed,no=P.no,yr=P.at.getFullYear(),nm=S.addr.b.name||"the customer",soItems=Q.T.concat(Q.C.map(x=>Object.assign({},x,{n:x.n*q}))).concat(Q.H.filter(x=>x.r));
|
||
$("#rvConf").innerHTML=`<div class="conf"><h3>✓ Order placed (simulated): CNX-${yr}-${no}</h3><p>Nothing was sent or charged. In the real platform the order is written to <b>ERPNext</b>, the plant's only system of record, through the plant's integration layer and its outbox; the public side never talks to ERPNext directly (digital factory, artist platform).</p>
|
||
<ol><li><b>Customer</b> <code>${esc(nm)}</code>: matched by e-mail or created, with the billing${S.ship!=="pickup"?" and shipping":""} address${S.split?" and the second address":""} as Address records.</li>
|
||
<li><b>Quotation</b> <code>SAL-QTN-${yr}-0${no}</code>: this configuration priced from the ERPNext price list ${PLV} (this page's price list is its prototype), ${soItems.length} items, valid 30 days.</li>
|
||
<li><b>Sales Order</b> <code>SAL-ORD-${yr}-0${no}</code>, made from the quotation: ${fmtInt(q)} copies, total ${money(Q.total)}, delivery window ${dRange(sw[0],sw[1])}.</li>
|
||
<li><b>Payment Request</b> <code>ACC-PRQ-${yr}-0${no}</code>: the ${pctT(R.deposit.v)} deposit, ${money(Q.total*R.deposit.v)}.</li>
|
||
<li><b>Release</b> ${esc(S.catno)} with ${sideNames().length} <b>Side</b> records (custom document types in the <code>cnx_erp</code> app) and three <b>Artist Gate</b> records waiting: master plan, master, test pressing.</li></ol>
|
||
<details><summary class="hint">Sales Order items</summary><div class="tscroll"><table><tbody><tr class="hd"><th>Item</th><th>Description</th><th class="num">Qty</th><th class="num">Rate</th></tr>${soItems.map(x=>`<tr><td><code>CNX-${esc((x.id||"item").toUpperCase())}</code></td><td>${esc(x.t)}</td><td class="num">${fmtInt(x.n)}</td><td class="num">${money(x.r)}</td></tr>`).join("")}</tbody></table></div></details>
|
||
<p><button type="button" class="btn sec" data-act="unplace">Back to the order</button></p></div>`;}
|
||
else $("#rvConf").innerHTML="";
|
||
}
|
||
|
||
/* ---- step 15: how to pay, the fan campaign panel ---- */
|
||
function renderFund(){
|
||
const F=fansOn(),E=econ(),fi=$("#fanPrice"),frg=$("#fanR"),fr=FAN_RNG[S.cur],cap=capOn(),cw=campWhy(E);
|
||
$("#fsCamp").disabled=!F;$("#fsCamp").title=F?"":ROUTEW;
|
||
if(fi.dataset.cur!==S.cur){const a=FAN_IN[S.cur];fi.dataset.cur=S.cur;fi.min=a[0];fi.max=a[1];fi.step=a[2];frg.min=fr[0];frg.max=fr[1];frg.step=fr[2];$("#fpCur").textContent=S.cur;
|
||
$("#fpChips").innerHTML=`<span>Try:</span>`+FP[S.cur].map(v=>`<button type="button" class="chip t" data-act="fpc" data-fp="${v}">${fmtInt(v)}</button>`).join("");}
|
||
if(document.activeElement!==fi)fi.value=fanIn();frg.value=Math.min(fr[1],Math.max(fr[0],fanIn()));rngP(frg);
|
||
$$("#fpChips .chip").forEach(c=>c.classList.toggle("on",Math.abs(toTHB(+c.dataset.fp)-S.fanPrice)<0.01));
|
||
$("#days").value=S.days;rngP($("#days"));$("#daysV").textContent=S.days+" days";
|
||
const ai=$("#acop");if(document.activeElement!==ai)ai.value=S.artistCopies;
|
||
$("#limT").checked=S.limited;const ci=$("#cap");if(document.activeElement!==ci)ci.value=S.capN;
|
||
ci.disabled=!F||!S.limited;ci.title=S.limited?"":"Tick the limited edition to set its size";$("#capF").classList.toggle("off",!S.limited);$("#capF").title=ci.title;
|
||
$("#vatT").checked=S.vat;
|
||
const H=priceHint(E);
|
||
$("#need").innerHTML=E.goal==null?`<span class="bad-t">This price does not cover the cost per copy.</span>`:`You need <b>${fmtInt(E.goal)} fans</b> to press it, ${money(E.goal*S.fanPrice)} in pre-orders`;
|
||
$("#needHint").textContent=E.goal==null?"Raise the price.":E.be>E.min?"Your break-even is "+E.be+" fans at this price, above the plant's "+E.min+"-copy minimum. From "+H.t+" a copy, "+E.min+" fans are enough."
|
||
:"The plant presses from "+E.min+" copies. Below about "+H.t+" a copy you would need more fans to cover the costs.";
|
||
let h="",hk="";
|
||
if(E.goal==null)hk=`<div class="note bad">At ${money(E.p)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price.</div>`;
|
||
else{const g=E.at(E.goal),rows=keepRows(E),mx=Math.max(...rows.map(r=>r.keep),1);
|
||
hk=`<div class="kpis"><div class="kpi hl"><small>Fans needed</small><b>${fmtInt(E.goal)} fans</b><em>${money(E.goal*S.fanPrice)} in pre-orders</em></div><div class="kpi"><small>Your break-even</small><b>${fmtInt(E.be)} fans</b><em>every cost covered</em></div><div class="kpi"><small>You keep at the goal</small><b>${money(g.per)}</b><em>a copy</em></div><div class="kpi"><small>That is</small><b>${money(g.keep)}</b><em>in total</em></div></div>
|
||
${cap&&cap<E.goal?`<div class="note bad">The edition is capped at ${fmtInt(cap)}, below the goal of ${fmtInt(E.goal)}: this campaign cannot succeed.</div>`:""}`;
|
||
h=`<h4>What you keep if more fans join</h4><div class="keep">${rows.map(r=>`<div class="krow${r.n===E.goal?" goal":""}"><span>${fmtInt(r.n)} fans</span><div class="kb"><i style="width:${Math.max(2,r.keep/mx*100).toFixed(1)}%"></i></div><div class="kv2"><b>${money(r.keep)}</b><small>${money(r.per)} a copy</small></div></div>`).join("")}</div>
|
||
<p class="hint">What you keep is after payment fees of about ${(R.fee.v*100).toFixed(1)} %${S.vat?" and VAT":""}, the one-off setup and the pressing. More fans than needed is good news: the one-off setup spreads over more copies. Shipping is paid by each fan at cost.</p>`;}
|
||
$("#fundKpi").innerHTML=hk;$("#fundEcon").innerHTML=h;
|
||
const b=$("#bLaunch"),n=missing().length;b.disabled=!!cw;b.title=cw;
|
||
$("#launchMsg").textContent=cw&&F?cw+".":n?"Launching opens the review first: "+n+" item"+(n>1?"s":"")+" to complete there.":"Simulated: no page goes live and nobody is charged.";
|
||
}
|
||
|
||
/* ---- the campaign simulation (make-a-record): launch, play, outcome ---- */
|
||
let TIMER=null;
|
||
function stopSim(){if(TIMER){clearInterval(TIMER);TIMER=null;}}
|
||
function resetSim(){stopSim();S.simDay=0;S.outcome=null;S.outcomeAct=null;S.finalFans=0;}
|
||
function resetCampaign(){resetSim();S.launched=false;S.launchDate=null;}
|
||
function checkEnd(){
|
||
if(S.simDay>=S.days){if(!S.outcome){const E=econ(),P=finalPledges(E);S.finalFans=P;S.outcome=P>=E.goal?"funded":"missed";}}
|
||
else if(S.outcome){S.outcome=null;S.outcomeAct=null;}
|
||
}
|
||
function startSim(){
|
||
stopSim();if(S.simDay>=S.days)resetSim();
|
||
TIMER=setInterval(()=>{S.simDay=Math.min(S.days,S.simDay+S.days/80);checkEnd();if(S.simDay>=S.days)stopSim();updateSim();},100);
|
||
}
|
||
function toggleSim(){if(TIMER)stopSim();else startSim();updateSim();}
|
||
function launch(){
|
||
if(campWhy(econ()))return;
|
||
if(missing().length){S.visited[RV]=true;go(RV);toast("Complete the items in the yellow box first");return;}
|
||
resetCampaign();S.launched=true;S.launchDate=new Date(TODAY);S.visited[RV]=true;go(RV);$("#pBody").scrollTop=0;window.scrollTo(0,0);
|
||
}
|
||
function campChanged(){if(S.launched)resetCampaign();renderAll();}
|
||
function setFanPrice(v){S.fanPrice=v;campChanged();}
|
||
function updateSim(){
|
||
if(!S.launched||!$("#simKpis"))return;
|
||
const E=econ(),ended=S.simDay>=S.days,n=pledgesNow(E),goal=E.goal||0,W=campWindow(),cap=capOn();
|
||
$$('#rvConf .seg[data-k="scen"] button').forEach(b=>b.classList.toggle("on",b.dataset.v===S.scen));
|
||
const sl=$("#simDay");sl.max=S.days;if(document.activeElement!==sl)sl.value=S.simDay;rngP(sl);
|
||
$("#dayLbl").textContent="Day "+Math.floor(S.simDay)+" of "+S.days;
|
||
$("#playBtn").innerHTML=TIMER?"❚❚ Pause":ended?"↺ Replay":"▶ Play";
|
||
const funded=ended&&n>=goal,soldOut=cap&&n>=cap,bi=$("#simBar");
|
||
bi.style.width=(goal?Math.min(100,n/goal*100):0).toFixed(1)+"%";bi.className=ended&&!funded?"bad":"";
|
||
$("#simKpis").innerHTML=`<div class="kpi hl"><small>Fans</small><b>${fmtInt(n)} / ${fmtInt(goal)}</b><em>${n>goal?Math.round(n/goal*100)+" % of the goal":"pre-orders so far"}</em></div><div class="kpi"><small>Held apart</small><b>${money(n*S.fanPrice)}</b><em>in the separate account</em></div><div class="kpi"><small>${ended?"Closed":"Days left"}</small><b>${ended?fmtDs(W.close):Math.max(0,Math.ceil(S.days-S.simDay))}</b><em>${ended?"campaign over":"closes "+fmtDs(W.close)}</em></div>`;
|
||
$("#simStatus").innerHTML=!ended?"If fewer than "+fmtInt(goal)+" fans pre-order by "+fmtD(W.close)+", everyone is refunded automatically."
|
||
:funded?`<b style="color:var(--accent)">✓ Funded by ${fmtInt(n)} fans${soldOut?", sold out":""}.</b> Pressing starts now.`
|
||
:S.outcomeAct==="min"?"<b>Going ahead at the "+E.min+"-copy minimum.</b>":S.outcomeAct==="refund"?"<b>Goal missed.</b> Every fan has been refunded.":"<b>Goal missed.</b> Refunds go out unless the artist covers the gap.";
|
||
let oc="";
|
||
if(ended&&goal){
|
||
if(funded){const A=E.at(n);
|
||
oc=`<div class="ocard"><h3>Funded</h3><p>${fmtInt(n)} fans pre-ordered. Their money leaves the separate account for production: the campaign becomes a production order and ships about ${dRange(W.ship[0],W.ship[1])}.</p><div class="kv"><span>Fan money after fees${S.vat?" and VAT":""}</span><b>${money(A.net)}</b><span>One-off setup</span><b>${money(-E.tSum)}</b><span>Pressing and packaging, ${fmtInt(A.run)} copies</span><b>${money(-A.run*A.c)}</b><span><b style="color:var(--fg)">You keep</b></span><b style="color:var(--accent);font-size:16px">${money(A.keep)}</b></div></div>`;}
|
||
else{const run=Math.max(n,E.min)+S.artistCopies,c=E.cAt(run),cost=E.tSum+run*c,net=n*E.net1,diff=net-cost,spare=Math.max(0,E.min-n);
|
||
if(S.outcomeAct==="refund")oc=`<div class="ocard plain"><h3>Refunded</h3><p>${fmtInt(n)} refunds of ${money(S.fanPrice)} went back to each fan's original payment method. Nothing was pressed; your master and artwork stay in your vault for another try.</p><button type="button" class="btn sec sm" data-act="retry">Run it again</button></div>`;
|
||
else if(S.outcomeAct==="min")oc=`<div class="ocard"><h3>Going ahead at the minimum</h3><p>${diff<0?"You paid the gap of <b>"+money(-diff)+"</b>":"The fans' money covered the minimum run; you keep <b>"+money(diff)+"</b>"}, and you receive the ${fmtInt(spare)} copies no fan claimed.</p></div>`;
|
||
else oc=`<div class="ocard warn"><h3>Goal missed: ${fmtInt(n)} of ${fmtInt(goal)}</h3><p>Two ways out. The artist chooses.</p><div class="opts"><div class="opt"><b>Refund everyone</b><p>Automatic, from the separate account. Nothing is pressed; your files stay in your vault.</p><button type="button" class="btn sec sm" data-act="refund">Refund all ${fmtInt(n)} fans</button></div><div class="opt"><b>Go ahead at the ${E.min}-copy minimum</b><p>${diff<0?"You pay the gap of <b>"+money(-diff)+"</b>":"Costs you nothing: the fans' money covers it and you keep "+money(diff)}. You receive the ${fmtInt(spare)} unclaimed copies.</p><button type="button" class="btn sm" data-act="goMin">Go ahead</button></div></div></div>`;
|
||
}
|
||
}
|
||
$("#simOut").innerHTML=oc;
|
||
}
|
||
|
||
function rateText(k){
|
||
const v=R[k].v,m=x=>money(x,{bare:true});
|
||
if(k==="press"||k==="innerPrint"||k==="carton")return `12" ${k==="carton"?v[12]:m(v[12])} · 10" ${k==="carton"?v[10]:m(v[10])} · 7" ${k==="carton"?v[7]:m(v[7])}${k==="carton"?"":" "+S.cur}`;
|
||
if(k==="colour")return `solid ${m(v.solid)} · marbled ${m(v.marbled)} · splatter ${m(v.splatter)} ${S.cur}`;
|
||
if(k==="colourFx")return `translucent ${m(v.trans)} · split ${m(v.split)} · colour-in-colour ${m(v.cic)} ${S.cur}`;
|
||
if(k==="inner")return `paper ${m(v.paper[12])} / ${m(v.paper[10])} / ${m(v.paper[7])}, poly-lined ${m(v.poly[12])} / ${m(v.poly[10])} / ${m(v.poly[7])} ${S.cur} (12" / 10" / 7")`;
|
||
if(k==="jacket")return `printed 12" ${v.printed[12].map(m).join(" / ")}, 10" ${v.printed[10].map(m).join(" / ")}, 7" ${v.printed[7].map(m).join(" / ")}; gatefold 12" ${v.gatefold[12].map(m).join(" / ")}; plain 12" ${m(v.plain[12][0])}, 10" ${m(v.plain[10][0])}, 7" ${m(v.plain[7][0])} ${S.cur}`;
|
||
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"||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);
|
||
}
|
||
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][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><td class="num">${(v[1]*60/SONG).toFixed(1)}</td></tr>`;}).join("");
|
||
$("#plBox").innerHTML=`<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 page 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 kept in THB; USD at 1 USD = 33 THB, EUR at 1 EUR = 38.115 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>${esc(R[k].t)}</td><td>${rateText(k)}</td><td><small>${esc(R[k].u)}</small></td><td><span class="badge b-${R[k].k}">${kb[R[k].k]}</span></td><td><small>${esc(R[k].src)}</small></td></tr>`).join("")}</tbody></table></div>
|
||
<h4>Side length per format</h4><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><th class="num">Songs</th></tr>${lim}</tbody></table></div>
|
||
<p class="hint">Comfortable: lower end of the ranges in microcut.studio's format guide (checked October 2026). At most: the team's table (October 2026, from recordplayerexpert.com, which counts songs at an average 4:32). Loud cuts: limits times 0.6, from Diggers Factory's dance-music figures. The engineer check decides, not this table.</p>
|
||
<h4>Lead time</h4><p class="hint">Stage durations: mature-plant column of the production timeline. Quote: 7 to 9 weeks for a new title, counted from test-pressing approval; 1 to 2 weeks less without a printed jacket; a repress from the archived mother 2 to 3 weeks. The ship date on the card adds the typical weeks before your approval, in working days from today.</p>
|
||
<h4>Not in this demo yet</h4><p class="hint">VAT on the invoice, carrier quotes for freight, duties paid in advance (DDP), glitter and etched sides, test-pressing-only and print-only orders, the payment provider and the platform's name, which is still an open decision.</p>`;
|
||
}
|
||
|
||
/* ---- render everything ---- */
|
||
function renderAll(){
|
||
normalizeView();sync();renderRail();renderHead();
|
||
const n=S.step;
|
||
if(n===13){renderSides();renderChecks();}
|
||
if(n===14)renderArt();
|
||
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>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","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)));
|
||
function stateHash(){return "#q="+b64e(JSON.stringify(cfg()));}
|
||
const catSan=v=>String(v||"").toUpperCase().replace(/[^A-Z0-9-]/g,"").slice(0,15);
|
||
const loSan=v=>String(v||"").replace(/[^A-Za-z0-9 .,!?'&\/:#()+-]/g,"").slice(0,60);
|
||
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","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)));
|
||
if(typeof o.catno==="string")S.catno=catSan(o.catno);if(typeof o.leadout==="string")S.leadout=loSan(o.leadout);
|
||
if(typeof o.artist==="string")S.artist=o.artist.slice(0,60);if(typeof o.title==="string")S.title=o.title.slice(0,80);
|
||
if(Array.isArray(o.dl)){S.dl=[newDisc(),newDisc(),newDisc(),newDisc()];o.dl.slice(0,4).forEach((x,i)=>{if(!x||!FX[x.fx]||FX[x.fx].off||!Array.isArray(x.c))return;const sl=FX[x.fx].slots;if(x.c.length!==sl.length||!x.c.every((c,j)=>PAL[sl[j][1]].includes(c)))return;S.dl[i].fx=x.fx;S.dl[i].c[x.fx]=x.c.slice();});}
|
||
const sn="ABCDEFGH";S.srpm={};S.stype={};
|
||
if(o.srpm&&typeof o.srpm==="object")Object.keys(o.srpm).forEach(k=>{if(sn.includes(k)&&k.length===1&&(o.srpm[k]==="33"||o.srpm[k]==="45"))S.srpm[k]=o.srpm[k];});
|
||
if(o.stype&&typeof o.stype==="object")Object.keys(o.stype).forEach(k=>{if(sn.includes(k)&&k.length===1&&(o.stype[k]==="audio"||o.stype[k]==="blank"))S.stype[k]=o.stype[k];});
|
||
if(Array.isArray(o.tr)){S.tracks=o.tr.filter(t=>Array.isArray(t)&&typeof t[0]==="string"&&Number.isFinite(t[1])).slice(0,60).map(t=>({id:++tid,title:t[0].slice(0,80),sec:Math.max(0,Math.min(3600,Math.round(t[1]))),side:sn.includes(t[2])&&String(t[2]).length===1?t[2]:"A",file:null,info:null,url:null}));S.demoTracks=false;}
|
||
S.vd=0;S.fxOpen=undefined;normalize();
|
||
}
|
||
let lastHash="";
|
||
function applyHash(h){if(!h||h.indexOf("#q=")!==0)return false;let o;try{o=JSON.parse(b64d(h.slice(3)));}catch(e){toast("That link could not be read");return false;}applyCfg(o);syncInputs();renderTracks();renderPL();renderAll();return true;}
|
||
function syncInputs(){
|
||
const sv=(id,v)=>{const e=$("#"+id);if(e&&document.activeElement!==e)e.value=v;};
|
||
sv("artist",S.artist);sv("title",S.title);sv("catno",S.catno);sv("catno2",S.catno);sv("leadout",S.leadout);sv("notes",S.notes);sv("genre",S.genre);sv("splitQty",S.splitQty);
|
||
["b","s","x"].forEach(g=>Object.keys(S.addr[g]).forEach(f=>sv("a_"+g+"_"+f,S.addr[g][f])));
|
||
}
|
||
|
||
/* ---- 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,quiet){const P=PRESETS[k];if(!P)return;S.size=P.size;S.discs=P.discs;if(P.jacket)S.jacket=P.jacket;P.dl.forEach((x,i)=>{S.dl[i]=pDisc(x);});
|
||
S.vd=0;S.fxOpen=P.dl[0].fx;S.viewMan=false;normalize();autoSplit();renderTracks();renderAll();if(!quiet)toast("Preset: "+P.t);}
|
||
/* short deep links for screenshots and demos: #s=<step>&p=<preset>&route=fans|direct&fp=<fan price THB>&theme=light|dark;
|
||
#fans is short for #s=15&route=fans, #fans=splat adds the preset. Example: order.html#s=15&p=splat&route=fans */
|
||
function applyShort(h){
|
||
if(!h||h.length<2||h.indexOf("#q=")===0)return false;
|
||
const P={};h.slice(1).split("&").forEach(x=>{if(!x)return;const i=x.indexOf("="),k=i<0?x:x.slice(0,i);let v="";try{v=i<0?"":decodeURIComponent(x.slice(i+1));}catch(e){}P[k]=v;});
|
||
if("fans" in P){if(!P.route)P.route="fans";if(!P.s)P.s=String(PAY);if(P.fans&&!P.p)P.p=P.fans;}
|
||
let any=false;
|
||
if((P.theme==="light"||P.theme==="dark")&&setTheme){setTheme(P.theme,false);any=true;}
|
||
if(P.p&&PRESETS[P.p]){applyPreset(P.p,true);any=true;}
|
||
if(P.route&&ENUM.route.includes(P.route)){if(S.route!==P.route){S.route=P.route;resetCampaign();S.placed=null;}any=true;}
|
||
if(P.fp){const v=Math.round(+P.fp);if(v>=100&&v<=20000){S.fanPrice=v;resetCampaign();any=true;}}
|
||
const st=Math.round(+P.s);
|
||
if(st>=1&&st<=STEPS.length){for(let i=1;i<st;i++)S.visited[i]=true;go(st);any=true;}
|
||
else if(any)renderAll();
|
||
return any;
|
||
}
|
||
const ACT={
|
||
go:b=>go(+b.dataset.s),
|
||
next:()=>go(S.step+1),
|
||
close:()=>{S.closed=true;renderAll();},
|
||
reopen:()=>{S.closed=false;renderAll();},
|
||
discs:b=>setK("discs",S.discs+(+b.dataset.d)),
|
||
dtab:b=>{S.vd=Math.max(0,Math.min(S.discs-1,+b.dataset.i));S.fxOpen=S.dl[S.vd].fx;renderAll();},
|
||
copyAll:()=>{const src=S.dl[S.vd];for(let i=0;i<4;i++)if(i!==S.vd)S.dl[i]=JSON.parse(JSON.stringify(src));renderAll();toast("Every disc now has the colour of disc "+(S.vd+1));},
|
||
fx:b=>{const fx=b.dataset.fx,F=FX[fx];if(!F||F.off)return;const d=S.dl[S.vd];if(d.fx!==fx){d.fx=fx;S.fxOpen=fx;}else S.fxOpen=(S.fxOpen===undefined?d.fx:S.fxOpen)===fx?null:fx;renderAll();},
|
||
sw:b=>{const fx=b.dataset.fx,si=+b.dataset.slot,c=b.dataset.c,F=FX[fx];if(!F||F.off||!F.slots[si]||!PAL[F.slots[si][1]].includes(c))return;const d=S.dl[S.vd];d.c[fx][si]=c;d.fx=fx;S.fxOpen=fx;renderAll();},
|
||
preset:b=>applyPreset(b.dataset.p),
|
||
tog:b=>{const k=b.dataset.b;if(!(k in S))return;S[k]=!S[k];renderAll();},
|
||
qty:b=>setQty(qStep(S.qty,+b.dataset.d)),
|
||
qmin:()=>{S.qmin=!S.qmin;renderCard();},
|
||
save:()=>{try{localStorage.setItem("cnx-order-saved",JSON.stringify({h:stateHash(),at:Date.now(),tot:quote(S.qty).total}));toast("Quote saved in this browser");}catch(e){toast("This browser does not allow saving");}renderCard();},
|
||
share:()=>{const h=stateHash();lastHash=h;history.replaceState(null,"",h);const u=location.href;(navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(u):Promise.reject()).then(()=>toast("Link copied: it opens this configuration (no addresses)"),()=>toast("The link is in the address bar: copy it to share"));},
|
||
restore:()=>{let sv=null;try{sv=JSON.parse(localStorage.getItem("cnx-order-saved")||"null");}catch(e){}if(sv&&sv.h&&applyHash(sv.h))toast("Saved quote restored");},
|
||
rm:b=>{const t=trkOf(b);S.tracks=S.tracks.filter(x=>x!==t);S.demoTracks=false;renderTracks();renderAll();},
|
||
add:()=>{const a=audioSides();S.tracks.push({id:++tid,title:"New track",sec:180,side:a[a.length-1]||"A",file:null,info:null,url:null});S.demoTracks=false;renderTracks();renderAll();},
|
||
split:()=>{autoSplit();renderTracks();renderAll();},
|
||
up:b=>{const t=trkOf(b),i=S.tracks.indexOf(t);if(i<=0)return;const p=S.tracks[i-1];if(p.side===t.side){S.tracks[i-1]=t;S.tracks[i]=p;}else t.side=p.side;S.demoTracks=false;renderTracks();renderAll();},
|
||
down:b=>{const t=trkOf(b),i=S.tracks.indexOf(t);if(i<0||i>=S.tracks.length-1)return;const nx=S.tracks[i+1];if(nx.side===t.side){S.tracks[i+1]=t;S.tracks[i]=nx;}else t.side=nx.side;S.demoTracks=false;renderTracks();renderAll();},
|
||
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(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===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;
|
||
const g=b.closest(".tiles[data-k], .seg[data-k]");
|
||
if(g&&b.dataset.v!=null){setK(g.dataset.k,typed(g,b.dataset.v));return;}
|
||
const sg=b.closest(".seg[data-sk]");if(sg&&b.dataset.v!=null){setSide(sg.dataset.sk,sg.dataset.side,b.dataset.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==="artist"||id==="title"){S[id]=t.value;renderAll();}
|
||
else if(id==="catno"||id==="catno2"){const v=catSan(t.value);if(v!==t.value){const c=t.selectionStart;t.value=v;try{t.setSelectionRange(c,c);}catch(_){}}S.catno=v;const o=$(id==="catno"?"#catno2":"#catno");if(o)o.value=v;renderAll();}
|
||
else if(id==="leadout"){const v=loSan(t.value);if(v!==t.value)t.value=v;S.leadout=v;renderAll();}
|
||
else if(id==="notes")S.notes=t.value;
|
||
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;
|
||
if(t.classList.contains("qtyIn"))setQty(t.value);
|
||
else if(t.id==="genre"){if(ENUM.genre.includes(t.value)){S.genre=t.value;renderAll();}}
|
||
else if(t.id==="splitChk"){S.split=t.checked;normalize();renderAll();}
|
||
else if(t.id==="sameChk"){if(S.ship!=="pickup"){S.same=t.checked;renderAll();}}
|
||
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 */
|
||
let dragId=null;
|
||
document.addEventListener("dragstart",e=>{const td=e.target.closest&&e.target.closest("td.drag");if(!td)return;dragId=+td.closest("tr").dataset.id;td.closest("tr").classList.add("dragging");e.dataTransfer.effectAllowed="move";try{e.dataTransfer.setData("text/plain",String(dragId));}catch(_){}});
|
||
document.addEventListener("dragover",e=>{if(dragId==null)return;const tr=e.target.closest&&e.target.closest("#trk tr[data-id]");if(!tr)return;e.preventDefault();$$("#trk tr.dover").forEach(x=>x.classList.remove("dover"));tr.classList.add("dover");});
|
||
document.addEventListener("drop",e=>{if(dragId==null)return;const tr=e.target.closest&&e.target.closest("#trk tr[data-id]");if(tr){e.preventDefault();const t=S.tracks.find(x=>x.id===dragId),at=S.tracks.find(x=>x.id===+tr.dataset.id);if(t&&at&&t!==at){S.tracks.splice(S.tracks.indexOf(t),1);t.side=at.side;S.tracks.splice(S.tracks.indexOf(at),0,t);S.demoTracks=false;}}dragId=null;renderTracks();renderAll();});
|
||
document.addEventListener("dragend",()=>{if(dragId!=null){dragId=null;renderTracks();}});
|
||
|
||
/* ---- files: drop zones ---- */
|
||
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=>{if(dragId!=null)return;e.preventDefault();if(!el.classList.contains("off"))el.classList.add("over");}));
|
||
["dragleave","drop"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.remove("over");}));
|
||
el.addEventListener("drop",e=>{if(el.classList.contains("off"))return;handler(Array.from(e.dataTransfer.files||[]));});
|
||
el.addEventListener("click",e=>{if(el.classList.contains("off"))e.preventDefault();});
|
||
input.addEventListener("change",()=>{handler(Array.from(input.files||[]));input.value="";});
|
||
}
|
||
async function handleAudio(files){
|
||
if(!files.length||rep())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:audioSides()[0]||"A",file:f,info:{reading:true},url:URL.createObjectURL(f)};S.tracks.push(t);return t;});
|
||
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();if(S.step===13)renderChecks();await scanPeak(t);t.info.scanning=false;renderTracks();renderAll();}
|
||
}
|
||
|
||
/* ---- 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);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);});
|
||
})();
|
||
|
||
/* ---- init ---- */
|
||
$("#pBody").innerHTML=stepHTML();
|
||
dropZone($("#dropAudio"),$("#fileAudio"),handleAudio);
|
||
dropZone($("#dropJacket"),$("#fileJacket"),f=>handleArt("jacket",f));
|
||
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();
|
||
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,
|
||
campEcon,econ,priceHint,keepRows,finalPledges,pledgesNow,campWhy,launch,startSim,stopSim,updateSim,fanIn,toTHB,setFanPrice,applyShort,PAY,RV};
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|