Makelab
Processing…
Reading files…
Large files (STEP, dense meshes) can take a moment.
📦

Drop your 3D files here

or click to browse — multiple files supported

STL · OBJ · 3MF · STEP · IGES · 3DM · PLY
Native CAD: SolidWorks, Inventor, Fusion 360, Creo, NX, CATIA, Parasolid
Converted automatically — takes a little longer.
✋Click and drag the cut plane to move it
50%
Spacing ×
1Configure› 2Lead Time & Delivery› 3Checkout› 4Confirmation
⏱ --:--:-- left — place your order by 3:30 PM ET today to keep these dates.
Order summary
PartQtyUnitPrice
Edit dimensions
Duplicate part
Unsupported file format

The Makelab Instant Quoter accepts these formats:

Working in SolidWorks, Fusion 360, CATIA, or another native CAD program? Export your part as STEP, STL, or OBJ from your CAD software before uploading.

Need to send a native CAD file? Send it to our engineering team for a manual quote.

Quote saved

Saved to your account.

You can do both. It also lives under Quotes in your account, where you can resume it or send it on later.

Choose your units
— × — × —
That's about — across.
These units will apply to all parts in this upload. You can change them per part later from the parts list.
Multiple bodies detected
 
Pick which bodies to add as separate parts. Unchecked bodies are kept together as one combined part.
Welcome to the parts panel

Each file you upload becomes a part with its own row.

You can change material, color, quantity, and scale per part. Multi-body files (e.g. one STL with several pieces) will prompt you to split them or keep them together.

'; const iframe = document.createElement('iframe'); iframe.setAttribute('aria-hidden', 'true'); iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'; document.body.appendChild(iframe); const idoc = iframe.contentWindow.document; idoc.open(); idoc.write(html); idoc.close(); iframe.contentWindow.onafterprint = function () { setTimeout(function () { iframe.remove(); }, 300); }; setTimeout(function () { try { iframe.contentWindow.focus(); iframe.contentWindow.print(); } catch (e) {} }, 600); } function _esc(x) { return String(x == null ? '' : x).replace(/[&<>"']/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; }); } function _addrLines(str) { let parts = String(str || '').split(',').map(function (x) { return x.trim(); }).filter(Boolean); if (!parts.length) return []; if (/^(usa|united states)$/i.test(parts[parts.length - 1])) parts = parts.slice(0, -1); if (parts.length <= 1) return parts; const cityState = parts.slice(-2).join(', '); return parts.slice(0, -2).concat([cityState]); } // WHAT THE CUSTOMER MARKED, LIVE OR REMEMBERED. // // This read only live 3D state: the `annotations` global, part._cosmetic, and // part.mesh.quaternion against part._origQuat. A REBUILT part -- a resumed cart // or a shared quote -- has none of the three, because the rebuild loops restore // the plain data (p.annotations, p.cosmetic, p.orientation) and the viewer does // not re-pin the model or re-paint the faces. So a customer who reopened their // own saved quote saw a checkout summary, and downloaded a PDF, with every pin, // cosmetic face and orientation they had marked simply missing -- and those are // the instructions the floor works to. // // Live state still wins where it exists; the stored value is the answer when // the tools have nothing to say. Same rule as _ckPartExtras. function _ckPartNotes(part) { const out = []; try { const live = (typeof annotations !== 'undefined' ? annotations : []).filter(function (a) { return a && a.partId === part.id; }); const pins = live.length ? live : (Array.isArray(part.annotations) ? part.annotations : []); pins.forEach(function (a) { const lbl = (typeof _annotPreset === 'function' ? ((_annotPreset(a.presetKey || a.preset) || {}).label || 'Note') : 'Note'); out.push(_esc(lbl + (a.note ? ': ' + a.note : ''))); }); } catch (e) {} try { const liveFaces = (part._cosmetic && part._cosmetic.tris && part._cosmetic.tris.size) || 0; const liveNote = (part._cosmetic && part._cosmetic.note) || ''; // The stored shape counts faces rather than carrying the triangle indices. const storedFaces = (part.cosmetic && Number(part.cosmetic.faces)) || 0; const storedNote = (part.cosmetic && part.cosmetic.note) || ''; const faces = liveFaces || storedFaces; const note = liveNote || storedNote; if (faces > 0) { out.push(_esc('Cosmetic surfaces (' + faces + ' face' + (faces === 1 ? '' : 's') + ')' + (note ? ' — ' + note : ''))); } else if (note) { out.push(_esc('Cosmetic note — ' + note)); } } catch (e) {} try { const turnedLive = !!(part._origQuat && part.mesh && part.mesh.quaternion && !part.mesh.quaternion.equals(part._origQuat)); if (turnedLive || (part.orientation && typeof part.orientation === 'object')) out.push('Custom print orientation set'); } catch (e) {} return out; } // What the chosen tier adds over Standard. The multiplier is baked into every // part's price, so this is already inside the subtotal — "+$0" said it was free. // Expedite is an EXTRA, so it belongs on its own line and nowhere else. The // delivery tier multiplies the part prices, which meant the subtotal itself moved // when you changed lead time - while a "+$X" lead-time line sat underneath it // showing the same premium a second time. The order total was right, but the two // numbers it was built from were both wrong. // // Split it: the subtotal is always the standard-tier price, and the difference // between the chosen tier and standard is the lead-time line. They still add up // to exactly what the server charges. function _ckTotalsSplit(applied, tierKey, chosen) { // A COMMITTED quote already records its lead-time charge: issued.leadTimeAmount // is the number a human stood behind when they sent it. Split on that rather // than re-deriving it from a tier multiplier -- the issued price also carries // finishing and material minimums, which do not scale with the tier, so a // ratio would put the wrong number on both lines. // // Without this the whole charge sat in Subtotal and the Lead time line read // nothing: a Next Day quote showed 291.98 against 157.55 of parts, with 134.44 // of lead time invisible (Christina 2026-08-05). const _lq = (typeof window !== 'undefined') ? window.__lockedQuote : null; const _lqAmt = _lq ? (Number((_lq.issued && _lq.issued.leadTimeAmount) || 0) || 0) : 0; if (_lq && _lqAmt) { const here = computeOrderMinimums(applied, tierKey || _lq.tier); const base = Object.assign({}, here, { subtotal: Math.round((here.subtotal - _lqAmt) * 100) / 100, grandTotal: Math.round((here.grandTotal - _lqAmt) * 100) / 100, }); return { base, leadDelta: _lqAmt }; } // Split from the baseline, not from a hardcoded 'standard': on a cart where // standard is not offered the rows are priced at the shortest available tier, // and the split has to reference the same one or Subtotal and Lead time stop // reconciling. const _bt = _baselineTier(); const std = computeOrderMinimums(applied, _bt); if (!chosen || !tierKey || tierKey === _bt) return { base: std, leadDelta: 0 }; const here = computeOrderMinimums(applied, tierKey); return { base: std, leadDelta: here.grandTotal - std.grandTotal }; } function _ckLeadAmtText(leadDelta) { if (Math.abs(leadDelta) < 0.005) return 'Included'; return leadDelta > 0 ? '+$' + formatPrice(leadDelta) : '\u2212$' + formatPrice(Math.abs(leadDelta)); } function leadTimeAmount(applied, tierKey, chosen) { if (!chosen) return '—'; try { var here = computeOrderMinimums(applied, tierKey); // Adjusts from the SAME baseline the part rows are priced at. Hardcoding // 'standard' here would, on a cart where standard is not offered, quote rows // from one tier and adjust from another — the two would not reconcile. var std = computeOrderMinimums(applied, _baselineTier()); var d = here.grandTotal - std.grandTotal; if (Math.abs(d) < 0.005) return 'Included'; return d > 0 ? '+$' + formatPrice(d) : '−$' + formatPrice(Math.abs(d)); // A computed zero is 'Included' -- a real answer. A THROWN one is not. // Returning 'Included' from the catch meant a pricing failure told the // customer the tier was free; they could choose it on that basis and be // charged. The em dash is what the unchosen case already shows. } catch (e) { return '—'; } } // -- Part rows: the receipt's shape, on every screen that draws one ----------- // // makelab-core/orders/partColumns is the spec: part, mfg details, qty, price, // in that order, money and qty right-aligned, sentence case throughout. // viewer.html is served raw to the browser and cannot import that module, so // detailsLine() and finishingNote() are copied below. A copy is precisely how // these drifted apart in the first place, so // tests/checkout-part-rows-match-receipt.test.mjs lifts both of these out, RUNS // them beside core's originals and requires them to agree. It does not read // this comment and take its word for it. // // What this screen used to print was // FDM · PLA · 40.0 × 20.0 × 10.0 mm · qty 2 // -- no colour, no quality, no infill. Three fields the customer picked, each // carrying its own price multiplier, absent from the summary they approve. The // same part read one way at checkout and a different way on the receipt. // makelab-core/orders/partColumns detailsLine(), verbatim. function _ckDetailsLine(spec) { var s = spec || {}; return [s.tech, s.material, s.color, s.quality, s.infill] .map(function (v) { return (v == null ? '' : String(v).trim()); }) .filter(Boolean) .join(' · '); } // makelab-core/orders/partColumns finishingNote(), verbatim. Finishing is named // as an inclusion in the price the row already shows, never added beside it -- // saying it twice is how a customer concludes they were billed twice. function _ckFinishingNote(spec, money) { var s = spec || {}; var list = Array.isArray(s.finishings) ? s.finishings : (s.finishing ? [s.finishing] : []); if (!list.length) return ''; var priced = list.filter(function (f) { return f && Number(f.price) > 0; }); var names = list.map(function (f) { return (typeof f === 'string' ? f : (f && (f.label || f.name)) || ''); }).filter(Boolean); if (!names.length) return ''; var total = priced.reduce(function (a, f) { return a + (Number(f.price) || 0); }, 0); var fmt = typeof money === 'function' ? money : (function (n) { return '$' + Number(n).toFixed(2); }); return total > 0 ? names.join(', ') + ' (incl. ' + fmt(total) + ')' : names.join(', '); } // The five canonical detail fields, as the customer chose them. Labels come from // the LIVE pricing model: the keys stored on a part are storage, not language, // and a renderer must never prettify a raw key into a name it invented. var _CK_TECH_LABELS = { fdm: 'FDM', sla: 'SLA', isla: 'Industrial SLA', mjf: 'MJF', sls: 'SLS', fgf: 'FGF' }; function _ckFactorLabel(kind, key) { if (key == null || String(key) === '') return ''; try { var opts = (typeof PRICING_MODEL !== 'undefined' && PRICING_MODEL && PRICING_MODEL.factorOptions && PRICING_MODEL.factorOptions[kind]) || []; for (var i = 0; i < opts.length; i++) { var o = opts[i]; // The admin model calls the quoter's 'natural' colour 'asmaterial'. var k = (kind === 'color' && String(o.key) === 'asmaterial') ? 'natural' : String(o.key); if (k === String(key) && o.label) return String(o.label); } } catch (e) {} return String(key); } function _ckPartSpec(v) { var s = v || {}; return { tech: s.tech ? (_CK_TECH_LABELS[s.tech] || String(s.tech).toUpperCase()) : '', material: s.material ? ((s.material === 'custom' && s.materialCustom) ? s.materialCustom : matDisplayName(s.tech, s.material)) : '', // The typed match, not the swatch's label. _ckFactorLabel would resolve the // key 'custom' to the model's own wording ("Custom color"), which is exactly // the word Christina asked to stop showing -- and the material line one row // up has read the typed text all along, so the same part described its // material by name and its colour by category. // // Read off the applied view, never written back to it: p.color stays // 'custom' so lib/finishing.mjs isCustomColor() still routes this cart to a // quote request instead of self-serve checkout. color: (s.color === 'custom' && s.colorCustom) ? s.colorCustom : _ckFactorLabel('color', s.color), quality: _ckFactorLabel('quality', s.resolution), // Infill exists on FDM only -- the configurator hides the step for every // other technology (availability('infill') requires tech === 'fdm'), so // printing a density against an SLA part would state a spec nobody chose. infill: (s.tech === 'fdm' && s.infillDensity != null) ? (Math.round(Number(s.infillDensity) * 100) + '%') : '', }; } // Finishing is a service the customer asked for, so it gets its own line instead // of being folded into the materials text where it reads like another material. // The wording is the receipt's, via _ckFinishingNote. // // No amount is stated here, deliberately. On THIS screen the row price does not // contain the finishing: computeFinishing() is added into the order total // separately, and on a quote-request cart that figure is explicitly approximate // (lib/finishing.mjs -- finishing cannot be bought self-serve). Printing // "(incl. $12.00)" beside a row price that excludes it would be a false // statement about money. _ckFinishingNote already knows how to say it the // moment the row price does carry it. function _ckFinLine(v) { var names = (typeof partFinishingLabels === 'function') ? partFinishingLabels(v) : []; var note = _ckFinishingNote({ finishings: names }); return note ? 'Finishing: ' + _escHtml(note) + '' : ''; } // ONE order-summary row builder for every checkout step. There were four // hand-copied versions and each had drifted somewhere different: the quote-request // step dropped the thumbnails, the payment and confirmation steps dropped the // dimensions, and none of them named the finishing the customer had asked and // paid for. The summary someone reads while deciding to buy must not change // depending on which page they happen to be on. // // full: the wide step-2 table, which has its own quantity and unit-price columns. // Everything else is the narrow rail, where quantity folds into the spec line. function _ckSummaryRows(applied, opts) { const full = !!(opts && opts.full); return (applied || []).map(function (p) { const _v = appliedView(p) || p; // Part rows are priced at STANDARD. The lead-time charge belongs once, in // the summary table, not spread invisibly across every line — Christina: // "part rows should be priced at standard and then the lead time charge // should be at the summary table". // // Without the explicit tier this falls back to the global orderDeliveryTier // (see calcAppliedPrice), so on any non-standard tier the rows were priced // at the chosen tier while the Subtotal beneath them was standard-tier — the // rows never summed to the figure under them. // The baseline tier, not a hardcoded 'standard': when a cart cannot be made // at standard the rows price from the shortest lead time still offered, and // the lead-time line adjusts from that same point. Both read one definition // so a row can never be priced from a different baseline than the line // beneath it. const price = calcAppliedPrice(p, { deliveryTier: _baselineTier() }); const thumb = (p.thumbnail && /^data:image\//.test(p.thumbnail)) ? '' : '
△
'; let dims = ''; try { const fu = p.displayUnit || 'mm'; const sv = p.scaleVec || { x: 1, y: 1, z: 1 }; const cx = envConvert(p.bboxRaw.x * sv.x, fu), cy = envConvert(p.bboxRaw.y * sv.y, fu), cz = envConvert(p.bboxRaw.z * sv.z, fu); const dp = cx.unit === 'in' ? 2 : 1; dims = cx.val.toFixed(dp) + ' × ' + cy.val.toFixed(dp) + ' × ' + cz.val.toFixed(dp) + ' ' + cx.unit; } catch (e) {} // The canonical five first, in the canonical order, then this screen's own // additions AFTER them: the printed size, and -- on the narrow rail, which // has no Qty column of its own -- the quantity. Added beside the canonical // fields, never interleaved with them and never renamed. const spec = [_ckDetailsLine(_ckPartSpec(_v)), dims, full ? '' : 'qty ' + _v.qty].filter(Boolean).join(' · '); const notes = _ckPartNotes(p).map(function (nt) { return '\u270E ' + nt + ''; }).join(''); const cells = full ? '' + _v.qty + '$' + formatPrice(price.perUnit) + '$' + formatPrice(price.total) + '' : '$' + formatPrice(price.total) + ''; return '' + thumb + '' + '' + _escHtml(p.name) + '' + _escHtml(spec) + '' + _ckFinLine(_v) + notes + '' + cells + ''; }).join(''); } // THE delivery row, one definition. renderCheckoutSummary built it inline and // the quote-request step built nothing at all -- while still adding the shipping // into its Est. total. Money inside a figure with no line to account for it. // _ckShipCost() is the amount; this is how that amount READS. function _ckDeliveryLine() { let shipLbl = 'Delivery', shipDetail = 'Not selected', shipVal = '—'; if (ckDispatchChosen) { if (orderDispatch === 'pickup') { shipDetail = 'Local pickup · at our factory'; shipVal = 'Free'; } else if (orderDispatch === 'courier') { shipDetail = 'Courier'; shipVal = '$15.00'; } else if (orderDispatch === 'shipping') { if (ckSelectedRate && ckSelectedRate._freight) { shipDetail = ckSelectedRate._label + ' · quoted separately'; shipVal = 'TBD'; } else if (ckSelectedRate) { shipDetail = `${ckSelectedRate.carrier} ${ckSelectedRate.service}${ckSelectedRate._arrival ? ' · est. ' + _ckDateFmt(ckSelectedRate._arrival) : ''}`; shipVal = '$' + ckSelectedRate.rate.toFixed(2); } else { shipDetail = 'UPS · pick a carrier'; shipVal = 'Pick a carrier'; } } // A free-shipping credit has nothing to waive on freight: the server prices // it at $0 and the real cost is quoted later. 'TBD' must not become 'Free'. if (_ckShipCreditOn() && shipVal !== 'Pick a carrier' && shipVal !== 'TBD') { shipDetail += ' · free-shipping credit applied'; shipVal = 'Free'; } } return { label: shipLbl, detail: shipDetail, val: shipVal }; } function renderCheckoutSummary() { const body = document.getElementById('ckSumBody'); const totalsEl = document.getElementById('ckTotals'); if (!body || !totalsEl) return; ensureValidTier(); const applied = _ckAppliedParts(); body.innerHTML = _ckSummaryRows(applied, { full: true }); const _split = _ckTotalsSplit(applied, orderDeliveryTier, ckLeadChosen); const mins = _split.base; const _leadDelta = _split.leadDelta; const tier = _ckSelectedTier(); const ready = ckLeadChosen ? addBusinessDays(productionStart(), tier.businessDays) : null; const leadDetail = ckLeadChosen ? `${tier.name} · ${tier.businessDays} biz day${tier.businessDays === 1 ? '' : 's'} · ready ${_ckDateFmt(ready)}` : 'Not selected'; const _leadAmt = ckLeadChosen ? _ckLeadAmtText(_leadDelta) : '\u2014'; const _del = _ckDeliveryLine(); const shipLbl = _del.label, shipDetail = _del.detail, shipVal = _del.val; const shipCost = _ckShipCost(); const _orderTotal = mins.grandTotal + _leadDelta; // == the tier-priced total the server charges const _disc = _ckDiscountAmount(_orderTotal); // The 3% invoicing fee, in the running total from the moment invoicing is // picked. The server charges it either way; showing it only in the payment // box meant the Total above disagreed with what was actually taken. const _pay = _ckPayTotals(_orderTotal, shipCost, _disc, _ckPayMethod); const _fee = _pay.fee; const _tax = _pay.tax; const grand = _pay.grand; const minRow = mins.adjustmentTotal > 0 ? `
Material minimum adjustment+$${formatPrice(mins.adjustmentTotal)}
` : ''; const discRow = _disc > 0 ? '
Discount (' + ((ckDiscount && ckDiscount.code) || '') + ')−$' + formatPrice(_disc) + '
' : ''; totalsEl.innerHTML = `
Subtotal${_rfqTilde()}$${formatPrice(mins.subtotal)}
` + minRow + `
Lead time${leadDetail}${_leadAmt}
` + `
${shipLbl}${shipDetail}${shipVal}
` + `${discRow}` + (_fee > 0 ? '
Invoicing fee (3%)$' + formatPrice(_fee) + '
' : '') + `
${_ckTaxLabel()}${_rfqTilde()}$${formatPrice(_tax)}
Total${_rfqTilde()}$${formatPrice(grand)}
` + _ckCreditRowsHtml(grand, { toggle: true }); renderDiscountField(); if (typeof renderLeadCalendar === 'function') renderLeadCalendar(); } // Read-only mini calendar visual: order date -> production days -> ready/dispatch, weekends/holidays struck. function _calPhase(date, placed, prodStart, dispatch, delivery, inMonth, pickup) { var dd = new Date(date.getFullYear(), date.getMonth(), date.getDate()); var t = dd.getTime(), plt = placed.getTime(), ot = prodStart.getTime(), pt = dispatch.getTime(), vt = delivery ? delivery.getTime() : null; var dow = dd.getDay(); var _hol = HOLIDAYS.has(_isoLocal(dd)); var _wknd = (dow === 0 || dow === 6); var off = _wknd || _hol; if (!inMonth) return 'pad'; if (t === plt) return 'order'; // black box = day the order is placed if (vt && t === vt && vt > pt) return 'deliv'; if (t === pt) return pickup ? 'deliv' : 'disp'; if (t >= ot && t < pt && !off) return 'proc'; // production includes its start day if (vt && t > pt && t < vt) return 'transit'; if (_hol) return 'holiday'; if (off) return 'off'; return 'none'; } var _PHASE_CLS = { order: 'ck-cd-order', deliv: 'ck-cd-deliv', disp: 'ck-cd-disp', proc: 'ck-cd-proc', transit: 'ck-cd-transit', holiday: 'ck-cd-holiday', off: 'ck-cd-off', pad: 'ck-cd-pad', none: '' }; var _PHASE_BAND = { order: 1, deliv: 1, disp: 1, proc: 1, transit: 1 }; function _calMonth(y, m, order, dispatch, delivery, pickup) { var first = new Date(y, m, 1); var gridStart = new Date(y, m, 1 - first.getDay()); var title = first.toLocaleString('en-US', { month: 'long', year: 'numeric' }); var phases = [], days = []; for (var i = 0; i < 42; i++) { var d = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i); days.push(d); phases.push(_calPhase(d, order, order, dispatch, delivery, d.getMonth() === m, pickup)); } var cells = ''; for (var j = 0; j < 42; j++) { var ph = phases[j], cls = 'ck-cd-cell' + (_PHASE_CLS[ph] ? ' ' + _PHASE_CLS[ph] : ''); var rad = ''; if (_PHASE_BAND[ph]) { var col = j % 7; var L = (col === 0 || phases[j - 1] !== ph) ? '6px' : '0'; var R = (col === 6 || phases[j + 1] !== ph) ? '6px' : '0'; rad = ' style="border-radius:' + L + ' ' + R + ' ' + R + ' ' + L + '"'; } cells += '
' + days[j].getDate() + '
'; } return '
' + title + '
SMTWTFS
' + cells + '
'; } function renderLeadCalendar() { var hosts = document.querySelectorAll('.ck-lead-cal'); if (!hosts.length) return; var _setCal = function (h) { hosts.forEach(function (el) { el.innerHTML = h; }); }; if (!_ckAppliedParts().length) { _setCal(''); return; } // The calendar used to be blanked on any quote-request cart. Production still // takes the days it takes and the holidays are still in the way, so removing it // answered "when might this be ready" with nothing at all. It stays; every date // it draws is already tilde-marked and the line under the timeline says the // whole thing is an estimate. var _rfqCal = (typeof cartNeedsRfq === 'function') && cartNeedsRfq(); _setCal = function (h) { hosts.forEach(function (el) { el.innerHTML = (el.id === 'ckLeadCalendarRail') ? '' : h; }); }; var tier = _ckSelectedTier(); var os = productionStart(); var order = new Date(os.getFullYear(), os.getMonth(), os.getDate()); var _n0 = new Date(); var placed = new Date(_n0.getFullYear(), _n0.getMonth(), _n0.getDate()); var ds = addBusinessDays(os, tier.businessDays); var _lqcdC = (typeof _lqCommittedDate === 'function') ? _lqCommittedDate() : null; if (_lqcdC && _lqcdC.holds) { try { ds = new Date(_lqcdC.readyDate + 'T12:00:00'); } catch (e) {} } var dispatch = new Date(ds.getFullYear(), ds.getMonth(), ds.getDate()); var delivery = null; if (typeof ckDispatchChosen !== 'undefined' && ckDispatchChosen) { if (orderDispatch === 'shipping' && typeof ckSelectedRate !== 'undefined' && ckSelectedRate && ckSelectedRate._arrival) { var dv = new Date(ckSelectedRate._arrival); delivery = new Date(dv.getFullYear(), dv.getMonth(), dv.getDate()); } else if (orderDispatch === 'courier' || orderDispatch === 'pickup') { delivery = dispatch; } } var hasDeliv = !!(delivery && delivery.getTime() > dispatch.getTime()); var end = hasDeliv ? delivery : dispatch; var isPickup = (typeof ckDispatchChosen !== 'undefined' && ckDispatchChosen && orderDispatch === 'pickup'); // One continuous rolling grid: order week -> ready/delivery week, dates flow across the month boundary. var gridStart = new Date(placed); gridStart.setDate(gridStart.getDate() - gridStart.getDay()); var gridEnd = new Date(end); gridEnd.setDate(gridEnd.getDate() + (6 - gridEnd.getDay())); var cellsArr = []; for (var _d = new Date(gridStart); _d.getTime() <= gridEnd.getTime(); _d.setDate(_d.getDate() + 1)) cellsArr.push(new Date(_d)); var phaseArr = cellsArr.map(function (dd) { return (dd.getTime() < placed.getTime() || dd.getTime() > end.getTime()) ? 'pad' : _calPhase(dd, placed, order, dispatch, delivery, true, isPickup); }); var cellHtml = ''; var _firstProc = true; for (var j = 0; j < cellsArr.length; j++) { var ph = phaseArr[j]; var cls = 'ck-cd-cell' + (_PHASE_CLS[ph] ? ' ' + _PHASE_CLS[ph] : ''); if (ph === 'proc' && _firstProc) { cls += ' ck-cd-prodstart'; _firstProc = false; } // only the production-start day is the darker gray var rad = ''; if (_PHASE_BAND[ph]) { var col = j % 7; var L = (col === 0 || phaseArr[j - 1] !== ph) ? '6px' : '0'; var R = (col === 6 || phaseArr[j + 1] !== ph) ? '6px' : '0'; rad = ' style="border-radius:' + L + ' ' + R + ' ' + R + ' ' + L + '"'; } cellHtml += '
' + cellsArr[j].getDate() + '
'; } var _sm = placed.toLocaleString('en-US', { month: 'long' }), _em = end.toLocaleString('en-US', { month: 'long' }); var title = (_sm === _em ? _sm : _sm + ' \u2013 ' + _em) + ' ' + end.getFullYear(); var grid = '
' + title + '
SMTWTFS
' + cellHtml + '
'; var n = new Date(); var isToday = order.getTime() === new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime(); // Production starts the first business day (productionStart()); it can be a day // or more AFTER the order is placed (weekend / past the 3:30pm cutoff). Make // that explicit so the order date isn't read as the production-start date. var _prodStart = order; // = productionStart() var _prodGap = _prodStart.getTime() !== placed.getTime(); var _prodChip = _prodGap ? ' → Production starts ' + formatDate(_prodStart) + '' : ''; var line = 'Order placed ' + formatDate(placed) + '' + _prodChip + ' → ' + tier.businessDays + ' biz day' + (tier.businessDays === 1 ? '' : 's') + ' → Ready ' + formatDate(dispatch) + ''; var _prodNote = _prodGap ? '
Placing your order doesn\u2019t start production \u2014 the ' + tier.businessDays + '-day clock begins ' + formatDate(_prodStart) + ', the next business day.
' : ''; if (hasDeliv) line += ' → Est. delivery ' + formatDate(delivery) + ''; var _hols = (typeof _ckHolidaysInRange === 'function') ? _ckHolidaysInRange(placed, end) : []; var legend = '
Order placedProduction' + (isPickup ? 'Ready for pickup' : (hasDeliv ? 'Dispatch & delivery' : 'Ready')) + '' + (_hols.length ? 'Holiday' : '') + '
'; var _holCallout = _hols.length ? '
Closed for ' + _hols.map(function (h) { return h.name + ' (' + formatDate(h.date) + ')'; }).join(', ') + '
' : ''; var _below = (typeof _ckStep === 'undefined' || _ckStep !== 3); var _delivWarn = (orderDispatch === 'shipping' && hasDeliv && !(typeof window !== 'undefined' && window._orderPlaced)) ? '
⚠ Delivery date is UPS’s estimate — it can shift due to weather or carrier delays outside our control.
' : ''; var _cutoffC = (!_rfqCal && !(typeof window !== 'undefined' && window._orderPlaced)) ? '
⏱ --:--:-- left — place your order by 3:30 PM ET today to keep these dates.
' : ''; _setCal('
Production calendar
' + (_rfqCal ? 'Estimated dates — final dates confirmed with your quote.' : 'Estimated dates — update with your lead time & delivery ' + (_below ? 'selections below.' : 'choices.')) + '
' + line + '
' + grid + _prodNote + _holCallout + _delivWarn + legend + _cutoffC + '
'); } // Build the Lead time accordion rows (deltas vs Standard). // Work backwards from a date the customer types to the lead time that makes it. // The tier is still what gets priced — this only chooses it. // Once an address exists we know shipping times, so the same deadline can be // answered properly: not "when it leaves us" but "when it reaches you". // // Production speed and shipping speed both move the date and both cost money, so // the cheap way to hit a deadline is not always the obvious one — a slower build // with quicker post often beats a rush build on ground. Rather than reason about // that, price every combination and take the cheapest that lands in time. function _needByOptions(rates) { const applied = _ckAppliedParts(); if (!applied.length) return []; const prod = productionStart(); const out = []; const base = computeOrderMinimums(applied, _baselineTier()).grandTotal; DELIVERY_TIERS.forEach(function (t) { if (typeof tierStatus === 'function' && tierStatus(t.key) === 'off') return; const ready = addBusinessDays(prod, t.businessDays); // What this speed costs over Standard — the same delta the lead-time rows show. const tierCost = computeOrderMinimums(applied, t.key).grandTotal - base; if (ckDispatchChosen && (orderDispatch === 'pickup' || orderDispatch === 'courier')) { // Neither adds transit: ready IS the day they get it. out.push({ tier: t, ship: null, arrives: ready, cost: tierCost + (orderDispatch === 'courier' ? 15 : 0) }); return; } (rates || []).forEach(function (rt) { // An own-label rate has no transit estimate; it cannot answer a deadline. if (rt._ownLabel || !rt.deliveryDays) return; out.push({ tier: t, ship: rt, arrives: addBusinessDays(ready, rt.deliveryDays), cost: tierCost + (Number(rt.rate) || 0) }); }); }); return out; } // Re-answer the deadline now that shipping options exist. // The last rates fetched, so a date typed after they arrived can be answered // without asking the carrier again. let _ckLastRates = null; let _needByResolving = false; function _resolveNeedByWithShipping(rates) { const input = document.getElementById('ckNeedBy'); const msg = document.getElementById('ckNeedByMsg'); if (!input || !msg || !input.value) return; // Belt and braces: anything that re-enters while this is running would be a // loop, and a loop here means repeated calls to a paid carrier API. if (_needByResolving) return; _needByResolving = true; try { _resolveNeedByInner(input, msg, rates); } finally { _needByResolving = false; } } function _resolveNeedByInner(input, msg, rates) { const options = _needByOptions(rates); if (!options.length) return; const r = solveArrival(input.value, options); if (!r.ok) { msg.className = 'warn'; msg.textContent = r.soonest ? ('Nothing gets there by then. The soonest is ' + _ckDateFmt(r.soonest.arrives) + ' on ' + r.soonest.tier.name + (r.soonest.ship ? ' with ' + r.soonest.ship._label : '') + '.') : 'We cannot work out a way to make that date.'; return; } const pick = r.pick; orderDeliveryTier = pick.tier.key; ckLeadChosen = true; ensureValidTier(); // The rate object carries _arrival computed against whatever tier was selected // when the rates were fetched. We have just changed the tier, so that date is // stale — and it is the one the summary and the timeline print. pick.arrives is // the same rate measured from the new tier's ready date. if (pick.ship) { ckSelectedRate = Object.assign({}, pick.ship, { _arrival: pick.arrives }); ckAddrConfirmed = true; } const how = pick.tier.name + (pick.ship ? ' with ' + pick.ship._label : ''); // A tier you have marked needs-a-quote can still be the cheapest way to hit // the date, but the customer must not be left thinking they can just pay. const _nq = (typeof tierStatus === 'function') && tierStatus(pick.tier.key) === 'review'; msg.className = _nq ? '' : 'ok'; msg.textContent = 'Arrives ' + _ckDateFmt(pick.arrives) + ' \u2014 ' + how + (_nq ? '. That speed needs a quote, so we will confirm it rather than charge you now.' : '. Cheapest of ' + r.considered + ' combinations that make it.'); const cur = document.getElementById('curLead'); if (cur) { cur.textContent = _ckSelectedTier().name; cur.classList.remove('empty'); } const cd = document.getElementById('curDelivery'); if (cd && pick.ship) { cd.textContent = pick.ship._label; cd.classList.remove('empty'); } // NOT renderDeliveryRows(): it blanks #ckCarrierRows and calls // fetchCarrierRates() again, which lands back here — an endless loop of UPS // lookups that left the rate list empty. The rows are already on screen; the // chosen one just needs marking. const _rows = document.getElementById('ckCarrierRows'); if (_rows && pick.ship) { _rows.querySelectorAll('.ck-row').forEach(function (row) { const r2 = rates[parseInt(row.dataset.rate, 10)]; row.classList.toggle('active', !!(r2 && r2.id === pick.ship.id && r2.service === pick.ship.service)); }); } renderLeadRows(); renderCheckoutTimeline(); renderCheckoutSummary(); if (typeof refreshCrumbs === 'function') refreshCrumbs(); if (typeof updateGrandTotal === 'function') updateGrandTotal(); if (typeof updateContinueState === 'function') updateContinueState(); try { qEvent('need_by_arrival_solved', { metadata: { needBy: input.value, tier: pick.tier.key, ship: pick.ship ? pick.ship.service : orderDispatch, considered: r.considered } }); } catch (e) {} } // Answer the deadline ONCE, when we actually can. // // It used to answer twice: pick a lead time as soon as a date was typed, then // reach back and change it after shipping was chosen. Making a choice and having // something move it later is worse than waiting — you cannot trust a selection // that might not be final. // // Pickup and courier arrive the day the parts are ready, so those can be answered // straight away. Shipping cannot: transit depends on where it is going, and we do // not know that yet. So it says so, and answers properly once the address lands. function _needByCanAnswer() { // orderDispatch starts at 'pickup' before anyone has picked anything, so // reading it alone would answer the deadline as though pickup were chosen — // no transit, an early date, and a promise that changes the moment they choose // shipping. That is the very thing this rework removed. Every money line in // this file pairs the two flags; so does this. if (!ckDispatchChosen) return false; if (orderDispatch === 'pickup' || orderDispatch === 'courier') return true; return orderDispatch === 'shipping' && !!_ckLastRates && _ckLastRates.length > 0; } function _applyNeedBy() { const input = document.getElementById('ckNeedBy'); const msg = document.getElementById('ckNeedByMsg'); if (!input || !msg) return; const hint = document.getElementById('ckNeedByHint'); // The hint explains what the field is for. Once the field is talking, it is // saying the same thing twice. const said = function (cls, text) { msg.className = cls; msg.textContent = text; if (hint) hint.style.display = text ? 'none' : ''; }; const raw = input.value; if (!raw) { said('', ''); return; } if (!_needByCanAnswer()) { // Nothing is selected here on purpose. Choosing a lead time now would only // be undone once transit is known. said('wait', !ckDispatchChosen ? 'Pick how it should reach you below and we will work out the cheapest way to hit that date.' : 'Add your address below and we will work out the cheapest way to hit that date.'); return; } if (hint) hint.style.display = 'none'; _resolveNeedByWithShipping(_ckLastRates || []); } function renderLeadRows() { const wrap = document.getElementById('ckLeadRows'); if (!wrap) return; ensureValidTier(); const prod = productionStart(); const applied = _ckAppliedParts(); const _over = window.__lockedQuote ? [] : applied.filter(p => typeof _partOverBuild === 'function' && _partOverBuild(p)); if (_over.length) { wrap.innerHTML = '
RFQ required — ' + (_over.length === 1 ? 'a part exceeds' : (_over.length + ' parts exceed')) + ' our build volume, so we\'ll confirm your lead time by quote. Choose a delivery method below and continue — we\'ll collect a few details next.
'; var _cl = document.getElementById('curLead'); if (_cl) { _cl.textContent = 'RFQ required'; _cl.classList.remove('empty'); } if (typeof updateContinueState === 'function') updateContinueState(); if (typeof updateAccDone === 'function') updateAccDone(); if (!_rfqDeliveryOpened && !ckDispatchChosen && typeof ckOpen === 'function') { _rfqDeliveryOpened = true; ckOpen('delivery'); } return; } const _lqcd = (typeof _lqCommittedDate === 'function') ? _lqCommittedDate() : null; if (_lqcd && _lqcd.holds) { wrap.innerHTML = ''; ckLeadChosen = true; var _cdl = document.getElementById('curLead'); if (_cdl) { _cdl.textContent = 'Ready by ' + _lqFmtDay(_lqcd.readyDate); _cdl.classList.remove('empty'); } if (typeof updateContinueState === 'function') updateContinueState(); if (typeof updateAccDone === 'function') updateAccDone(); return; } let avail = availableTiers(); if (window.__lockedQuote) { // A quote that COMMITTED to one speed is ordered at that speed. // // But a "client chooses" quote promised the opposite -- the whole point of // that option is that the customer picks at checkout. This pinned the list // to the quote's own tier either way, so Q-15560 went out offering every // lead time and the client was shown exactly one row: Standard (Christina // 2026-08-05, "if I choose that option, they should see everything"). // // On a choose quote, offer what the instant quoter would offer for these // parts -- availableTiers() already applies the same rules, so a cart with // FDM and nylon in it shows the nylon's lead times, not the FDM ones. const _lqMode = (window.__lockedQuote.issued && window.__lockedQuote.issued.leadTimeMode) || null; if (_lqMode === 'choose') { // Offer only what they can actually BUY. A tier that normally routes to an // RFQ is not a choice on a quote we have already priced by hand -- staff // either committed to it when sending, or it is not on the table // (Christina 2026-08-05: "it should not offer rfq needed lead times unless // i specifically choose that option for them"). tierStatus still reports // 'review' for these; this is the one place that decides they are not shown. avail = avail.filter(function (t) { return t.key === window.__lockedQuote.tier || tierStatus(t.key) !== 'review'; }); if (!avail.length) avail = DELIVERY_TIERS.filter(function (t) { return t.key === window.__lockedQuote.tier; }); } if (_lqMode !== 'choose') { avail = DELIVERY_TIERS.filter(t => t.key === window.__lockedQuote.tier); if (!avail.length) avail = DELIVERY_TIERS.filter(t => t.key === 'standard'); } } const stdTier = avail.find(t => t.key === 'standard') || avail.find(t => t.businessDays === 3) || avail[avail.length - 1]; const baseTotal = stdTier ? computeOrderMinimums(applied, stdTier.key).grandTotal : 0; const _lqLapsed = (_lqcd && !_lqcd.holds) ? '
The committed ready date (' + _lqFmtDay(_lqcd.readyDate) + ') needed an order by ' + _lqFmtDay(_lqcd.orderByDate) + ', 3:30 PM ET — dates below follow our standard schedule. Your price is unchanged.
' : ''; wrap.innerHTML = _lqLapsed + avail.map(t => { const _days = _ckTierDays(t); const date = addBusinessDays(prod, _days); const total = computeOrderMinimums(applied, t.key).grandTotal; let priceHtml; if (stdTier && t.key === stdTier.key) { priceHtml = `+$0`; } else { const delta = total - baseTotal; if (Math.abs(delta) < 0.005) { priceHtml = `+$0`; } else { const cls = delta > 0 ? 'up' : 'down'; const sign = delta > 0 ? '+' : '−'; priceHtml = `${sign}${formatPrice(Math.abs(delta))}`; } } const active = (ckLeadChosen && t.key === orderDeliveryTier) ? ' active' : ''; return ``; }).join(''); wrap.querySelectorAll('.ck-row').forEach(row => { row.addEventListener('click', () => { orderDeliveryTier = row.dataset.tier; ensureValidTier(); ckLeadChosen = true; try { qEvent('lead_time_selected', { metadata: { tier: orderDeliveryTier } }); } catch (e) {} try { _logCheckoutSnapshot(); } catch (e) {} const cur = document.getElementById('curLead'); if (cur) { cur.textContent = _ckSelectedTier().name; cur.classList.remove('empty'); } wrap.querySelectorAll('.ck-row').forEach(r => r.classList.toggle('active', r.dataset.tier === orderDeliveryTier)); // re-render dependent UI renderCheckoutTimeline(); renderCheckoutSummary(); renderLeadRows(); renderDeliveryRows(); // A tier that needs a quote turns the rest of the checkout into a quote // request, so the step names have to follow. if (typeof refreshCrumbs === 'function') refreshCrumbs(); // reflect in sidebar (lead time changes per-part price) if (typeof updateGrandTotal === 'function') updateGrandTotal(); const _sel = parts.filter(p => selectedIds.has(p.id)); if (typeof updatePartPrice === 'function') updatePartPrice(_sel); if (typeof renderPartsList === 'function') renderPartsList(); updateContinueState(); ckOpen('delivery'); }); }); renderLeadCalendar(); } function renderDeliveryRows() { const wrap = document.getElementById('ckDeliveryRows'); if (!wrap) return; try { _ensureCourierZips(); } catch (e) {} const tier = _ckSelectedTier(); const ready = ckLeadChosen ? addBusinessDays(productionStart(), tier.businessDays) : null; const readyTxt = ready ? ('ready ' + _ckDateFmt(ready) + ' · at our factory') : 'at our factory'; // A pallet does not go on a bike and does not go as a parcel, so a freight // quote must not offer either -- an option that cannot actually happen is // worse than no option (Christina 2026-08-04: "when pallet/crate is selected // the other options should not exist. only local pickup"). Pickup stays, and // freight stays as a TBD row so the order can still be placed. const _frRow = _ckFreight(); wrap.innerHTML = `` + (_frRow ? '' : ``) + ``; wrap.querySelectorAll('.ck-row').forEach(row => { row.addEventListener('click', () => { const prev = orderDispatch; orderDispatch = row.dataset.method; ckDispatchChosen = true; try { qEvent('dispatch_selected', { metadata: { dispatch: orderDispatch } }); } catch (e) {} try { _logCheckoutSnapshot(); } catch (e) {} if (orderDispatch !== 'shipping') ckSelectedRate = null; // Address persists across courier/shipping so it isn't re-entered. const cur = document.getElementById('curDelivery'); const label = orderDispatch === 'shipping' && _frRow ? _frRow.label : { pickup: 'Local pickup', courier: 'Courier', shipping: 'Shipping' }[orderDispatch]; if (cur) { cur.textContent = label; cur.classList.remove('empty'); } wrap.querySelectorAll('.ck-row').forEach(r => r.classList.toggle('active', r.dataset.method === orderDispatch)); renderDeliveryDetail(); renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); // Pickup and courier arrive the day parts are ready, so a deadline typed // earlier can be answered the moment one of them is chosen. Shipping still // has to wait for an address. _needByOnDispatchChange(); if (orderDispatch === 'pickup' && !ckPickupConfirmed) openPickupModal(); }); }); renderDeliveryDetail(); } // Per-method detail shown under the delivery rows: // pickup -> confirmation checkbox + read-only factory address // courier -> address input (autocomplete) + ZIP-confirmation note // shipping-> (handled in the Carrier accordion) function closePickupModal() { const m = document.getElementById('pickupModal'); if (m) m.classList.remove('visible'); } function openPickupModal() { let m = document.getElementById('pickupModal'); if (!m) { m = document.createElement('div'); m.id = 'pickupModal'; m.className = 'format-modal-backdrop'; m.innerHTML = '
' + '
Confirm local pickup
' + '

You’ve chosen Local pickup — your parts will be ready to collect at our Brooklyn factory. We will not ship this order.

' + '
📍 Makelab factory
13 42nd St, Brooklyn, NY 11232
Pickup Mon–Fri, 10am–5pm
' + '

Your parts will be ready by 3:30pm on your dispatch date. We’ll email you the moment they’re off the machine and packed — usually earlier. Wait for that email before heading over so you’re not making a trip for parts that are still printing.

' + '' + '
' + '' + '' + '
' + '
'; document.body.appendChild(m); m.addEventListener('click', (e) => { if (e.target === m) closePickupModal(); }); } const ack = m.querySelector('#pickupModalAck'); const confirmBtn = m.querySelector('#pickupModalConfirm'); ack.checked = false; confirmBtn.disabled = true; ack.onchange = () => { confirmBtn.disabled = !ack.checked; }; confirmBtn.onclick = () => { ckPickupConfirmed = true; closePickupModal(); renderDeliveryDetail(); renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); }; m.querySelector('#pickupModalCancel').onclick = closePickupModal; m.classList.add('visible'); } function updateAddrVerified() { const el = document.getElementById('ckAddrVerified'); { var _cp = document.getElementById('ckCourierPrompt'); if (_cp) _cp.style.display = (orderDispatch === 'courier' && _ckAddrZip) ? 'none' : ''; } if (!el) return; if (!_ckAddrZip) { el.innerHTML = ''; return; } if (orderDispatch === 'courier') { try { if (typeof _ensureCourierZips === 'function') _ensureCourierZips(); } catch (e) {} if (typeof _courierZoneOk === 'function' && !_courierZoneOk(_ckAddrZip)) { el.innerHTML = '✗ Sorry, courier isn\u2019t available for ' + _ckAddrZip + ' — choose shipping or pickup above.'; } else { el.innerHTML = '✓ Verified — we can courier to this address'; } } // Shipping deliberately says nothing here. 'Address verified' on the strength // of having a ZIP sat directly above the carrier's own answer — which might be // a correction, or a warning that it cannot be delivered — so the page claimed // verified and not-verified in the same breath. The carrier result in // #ckAddrCheck is the real one and is the only one shown. else if (orderDispatch === 'shipping') el.innerHTML = ''; else el.innerHTML = ''; } // Picking pickup or courier is enough to answer a deadline, since neither adds // transit. Picking shipping is not — that still needs an address. function _needByOnDispatchChange() { try { if (orderDispatch !== 'shipping') _ckLastRates = null; _applyNeedBy(); } catch (e) {} } function renderDeliveryDetail() { const host = document.getElementById('ckDeliveryDetail'); if (!host) return; if (!ckDispatchChosen) { host.innerHTML = ''; return; } if (orderDispatch === 'pickup') { if (ckPickupConfirmed) { host.innerHTML = '
' + '
✓ Pickup confirmed
' + '
Makelab factory
13 42nd St, Brooklyn, NY 11232
Ready by 3:30pm on your dispatch date — we’ll email you the moment your parts are packed. Wait for that email before heading over.
' + '' + '
'; const ch = document.getElementById('ckPickupChange'); if (ch) ch.addEventListener('click', openPickupModal); } else { host.innerHTML = '
' + '' + '
Makelab factory
13 42nd St, Brooklyn, NY 11232
Ready by 3:30pm on your dispatch date — we’ll email you the moment your parts are packed. Wait for that email before heading over.
' + '
'; const b = document.getElementById('ckPickupConfirmBtn'); if (b) b.addEventListener('click', openPickupModal); } return; } if (orderDispatch === 'courier') { host.innerHTML = '
' + '
Let’s confirm we can courier this to you.
' + // A returning customer holds the same addresses whichever way the parcel // travels, so the picker belongs here too rather than only on shipping. '
' + '
' + '' + '' + '
' + '
' + '
'; const input = document.getElementById('ckCourierAddrInput'); const sugg = document.getElementById('ckCourierAddrSugg'); _ckMountSavedPicker('ckcourier', function (a) { // Courier only needs a destination, not a full shipping form — so picking // a saved address fills the one input and re-runs the zone check. const line = _ckAddrLine(a); _ckAddr = Object.assign({}, _ckAddr, { line1: a.line1 || '', line2: a.line2 || '', city: a.city || '', state: a.state || '', zip: a.zip || '', formatted: line, }); _ckAddrZip = a.zip || _ckAddrZip; ckAddrConfirmed = true; if (input) input.value = line; updateAddrVerified(); renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); }); wireAddressAutocomplete(input, sugg, function (addr) { _ckAddr = addr || _ckAddr; _ckAddrZip = (addr && addr.zip) || _ckAddrZip; ckAddrConfirmed = true; updateAddrVerified(); renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); }); updateAddrVerified(); return; } if (orderDispatch === 'shipping') { // Freight still needs a delivery address -- we have to put the pallet // somewhere, and validating it here is the only place the customer will // ever type it (Christina 2026-08-04: "keep the address validation BUT do // not display any rates of any sort"). // // What must never appear is a RATE. Freight cannot be priced from a box // size, so any number UPS returns for it is wrong, and a wrong number the // customer has already agreed to is worse than no number. The address block // renders exactly as it always did; only the rate rail below it changes. const _frD = _lqFreight(); if (_frD) { const _dsc = (v) => (v == null ? '' : String(v)).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); ckSelectedRate = { carrier: 'Makelab', service: 'Freight', _label: _frD.label, rate: 0, _arrival: null, _freight: true }; host.innerHTML = '
' + '
' + _dsc(_frD.label) + '. Too large to ship as a parcel. ' + 'Enter the delivery address and we will quote the freight separately, confirming the cost with you ' + 'before it ships — it is not included in the total below.
' + (_frD.note ? '
' + _dsc(_frD.note) + '
' : '') + '
' + '
' + '' + '' + '
' + '
Apt, suite or floor is asked on the next step.
' + '
' + // Deliberately no #ckCarrierRows: no rail, no rates, nothing to pick. '
'; wireCarrierAddress(); updateAddrVerified(); renderCheckoutSummary(); updateContinueState(); return; } host.innerHTML = '
' + '
Pick or enter your address to get live UPS rates and arrival dates.
' + '
' + '
' + '' + '' + '
' + '
Apt, suite or floor is asked on the next step.
' + '
' + '
' + '
Pick your address to see UPS rates and exact arrival dates.
' + '
'; wireCarrierAddress(); updateAddrVerified(); if (_ckAddrZip) fetchCarrierRates(); return; } host.innerHTML = ''; } // ── Address autocomplete (Google Places) — degrades to manual entry ── // Manual typing ALWAYS works and a non-empty typed address counts as confirmed. // The Places API is a pure enhancement: any failure (404/401/503/network — e.g. // running the static file without `next dev`) silently disables suggestions. // `kind` reaches api/places.mjs, which offers a PO box only when it is // 'billing'. A carrier cannot deliver to one, so a shipping field must never // suggest it. function wireAddressAutocomplete(input, sugg, onConfirm, requirePick, kind) { if (!input) return; let timer = null; let placesDisabled = false; // once the API errors, stop trying for this input // Manual entry: any non-empty value is a usable (typed) address. input.addEventListener('input', () => { const q = input.value.trim(); ckAddrConfirmed = false; // typing never confirms; pick a suggestion or blur a complete address updateContinueState(); if (placesDisabled || !sugg) return; clearTimeout(timer); if (q.length < 3) { sugg.style.display = 'none'; sugg.innerHTML = ''; return; } timer = setTimeout(async () => { try { const r = await fetch('/api/places', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'autocomplete', input: q, kind: kind || null }) }); if (!r.ok) { sugg.style.display = 'none'; sugg.innerHTML = ''; return; } const j = await r.json(); const list = (j && j.suggestions) || []; if (!list.length) { sugg.style.display = 'none'; sugg.innerHTML = ''; return; } sugg.innerHTML = list.map(it => '
  • ' + _escHtml(it.text) + '
  • ').join(''); sugg.style.display = ''; sugg.querySelectorAll('li').forEach(li => li.addEventListener('click', () => pickAddressSuggestion(li.dataset.pid, li.textContent, input, sugg, onConfirm))); } catch (e) { sugg.style.display = 'none'; sugg.innerHTML = ''; } }, 250); }); input.addEventListener('blur', () => { if (requirePick) return; setTimeout(() => { const q = input.value.trim(); if (!ckAddrConfirmed && q.length >= 6) { ckAddrConfirmed = true; if (!_ckAddr) _ckAddr = {}; _ckAddr.formatted = input.value; if (onConfirm) onConfirm(_ckAddr); updateContinueState(); } }, 180); }); } // User picked a Places suggestion: fetch structured details. On any failure, // keep the typed text and treat it as a manually-confirmed address. async function pickAddressSuggestion(placeId, text, input, sugg, onConfirm) { if (input) input.value = text; if (sugg) { sugg.style.display = 'none'; sugg.innerHTML = ''; } let addr = { formatted: text }; try { const dr = await fetch('/api/places', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'details', placeId }) }); if (dr.ok) { const dj = await dr.json(); if (dj && dj.address) { addr = Object.assign({ formatted: text }, dj.address); } } } catch (e) { /* keep typed text */ } ckAddrConfirmed = true; if (onConfirm) onConfirm(addr); } // Carrier accordion (shipping): wire its address input to the rate-fetching flow. // The saved-address chips, wired to the rate lookup rather than to a form: a // pick fills the box AND fetches rates, which is the whole reason you are here. function _mountRateSavedPicker() { var host = document.getElementById('ckRateSaved'); if (!host) return; _ckLoadSavedAddrs(function (list) { try { var h = document.getElementById('ckRateSaved'); if (!h) return; if (!list || !list.length) { h.innerHTML = ''; return; } var chips = list.map(function (a, i) { var lbl = a.name || a.line1 || 'Saved address'; var sub = [a.line1, a.city, a.state, a.zip].filter(Boolean).join(', '); return ''; }).join(''); h.innerHTML = '
    Use a saved address
    ' + '
    ' + chips + '
    ' + '
    or type a new one
    '; h.querySelectorAll('.ck-rate-chip').forEach(function (b) { b.addEventListener('click', async function () { var a = list[+b.getAttribute('data-i')]; if (!a) return; var formatted = _ckAddrLine(a); _ckAddr = Object.assign({}, _ckAddr || {}, { line1: a.line1, line2: a.line2, city: a.city, state: a.state, zip: a.zip, formatted: formatted }); _ckAddrZip = a.zip || null; // Keep the shipping card in step with the pick, so the address is not // asked for a second time later. _ckShip = Object.assign({}, _ckShip || {}, { name: a.name || '', company: a.company || '', line1: a.line1 || '', line2: a.line2 || '', city: a.city || '', state: a.state || '', zip: a.zip || '', phone: a.phone || '' }); var box = document.getElementById('ckAddrInput'); if (box) box.value = formatted; try { updateAddrVerified(); } catch (e) {} try { await fetchCarrierRates(); } catch (e) {} try { updateContinueState(); } catch (e) {} }); }); } catch (e) {} }); } function wireCarrierAddress() { const input = document.getElementById('ckAddrInput'); const sugg = document.getElementById('ckAddrSugg'); _mountRateSavedPicker(); if (!input) return; wireAddressAutocomplete(input, sugg, async function (addr) { _ckAddr = addr || _ckAddr; _ckAddrZip = (addr && addr.zip) || null; updateAddrVerified(); await fetchCarrierRates(); updateContinueState(); }); } function _upsServiceName(s) { const k = (s || '').replace(/[^a-z0-9]/gi, '').toLowerCase(); let name; // Order matters: every UPS overnight service starts with the same string, so // the most specific has to be tested first. 'NextDayAirEarlyAM' contains // 'nextdayair', so it was being labelled plain 'Next Day Air' — two rows with // the same name and different prices, and no way to tell which was which. // Same trap on '2ndDayAirAM'. if (k.includes('groundsaver') || k.includes('surepost')) name = 'Ground Saver'; else if (k.includes('3dayselect') || k.includes('threedayselect')) name = '3 Day Select'; else if (k.includes('nextdayairearly')) name = 'Next Day Air Early'; else if (k.includes('nextdayairsaver')) name = 'Next Day Air Saver'; else if (k.includes('nextdayair')) name = 'Next Day Air'; else if (k.includes('2nddayairam') || k.includes('seconddayairam')) name = '2nd Day Air A.M.'; else if (k.includes('2nddayair') || k.includes('seconddayair')) name = '2nd Day Air'; else if (k.includes('ground')) name = 'Ground'; else name = (s || '').replace(/^UPS\s*/i, '').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/(\d)([A-Za-z])/g, '$1 $2'); return 'UPS ' + name; } // EasyPost will rate an address the carrier cannot actually deliver to, so the // verification result has to be shown, not just collected. A correction is // offered as a button rather than applied silently - the person who typed the // address is the one who knows whether the correction is right. function _ckShowAddressCheck(info, sent) { const host = document.getElementById('ckAddrCheck'); if (!host) return; if (!info || info.verified == null) { host.innerHTML = ''; return; } const n = info.normalized || {}; const same = function (x, y) { return String(x || '').trim().toLowerCase() === String(y || '').trim().toLowerCase(); }; // NO WARNING WHEN THE CARRIER CANNOT VERIFY. // // Christina, 2026-09-04: "remove this part. they just have to enter it later // or not." EasyPost was returning "House number is invalid" for addresses // that are perfectly real -- 181 NW Highland Dr, Shoreline WA among them -- // and a red banner on a valid address costs a checkout for nothing. An // apartment or suite is asked for on the next step anyway, and the carrier // validates again for real when the label is bought. // // The CORRECTION offer below is kept: suggesting a better address is useful, // refusing to confirm one is not. if (info.verified === false) { host.innerHTML = ''; return; } // A unit number is the difference between delivered and returned, and the // carrier's normalised form does not always carry one back. Offering a // "correction" that has dropped the apartment — and rendering it without the // line2 the customer typed — is how a parcel ends up in a lobby. // // So: never offer a correction that would lose a unit we were given. Keep ours // when the carrier returns none, and if theirs is the ONLY difference, there is // nothing worth correcting. const keptLine2 = n.line2 || sent.line2 || ''; const wouldDropUnit = !!(sent.line2 && !n.line2); const differs = n.line1 && (!same(n.line1, sent.line1) || !same(n.city, sent.city) || !same(n.state, sent.state) || !same(n.zip, sent.zip)); if (differs && !wouldDropUnit) { const line = [_ckTitleCase(n.line1), keptLine2, _ckTitleCase(n.city), (n.state || '').toUpperCase(), n.zip].filter(Boolean).join(', '); host.innerHTML = '
    The carrier has this as ' + _escHtml(line) + '' + '
    '; const btn = document.getElementById('ckAddrUse'); if (btn) btn.addEventListener('click', function () { _ckAddr = Object.assign({}, _ckAddr, { line1: n.line1, line2: keptLine2, city: n.city, state: n.state, country: n.country, formatted: line, }); _ckAddrZip = n.zip; const cur = document.getElementById('curAddress'); if (cur) { cur.textContent = line; cur.classList.remove('empty'); } const ci = document.getElementById('ckCourierAddrInput'); if (ci) ci.value = line; host.innerHTML = '
    Address confirmed by the carrier.
    '; fetchCarrierRates(); }); return; } if (wouldDropUnit) { // Confirmed, and we are keeping what they typed rather than the carrier's // unit-less version. Say so, so a missing apartment is not a silent choice. host.innerHTML = '
    Address confirmed by the carrier — keeping ' + _escHtml(sent.line2) + '.
    '; return; } host.innerHTML = '
    Address confirmed by the carrier.
    '; } // Carriers return addresses shouted in capitals. We do not write in capitals. function _ckTitleCase(str) { return String(str || '').toLowerCase().replace(/\b([a-z])/g, function (m0, c) { return c.toUpperCase(); }); } // WHY A FAILURE HAS TO NAME ITSELF. // // Christina, 2026-08-30, testing live: "its not getting shipping rates." She // was signed out -- account, pay-mode, orders and geometry were all 401ing in // her console, and ship-rates sits behind the SAME session gate -- but this // panel said "No UPS rates for that address". So the page blamed the one thing // that was not wrong, and gave her nothing to act on. // // One branch was carrying four different failures. Each one now says what it // SO THE CUSTOMER KNOWS WHAT IS TURNING UP. // // Christina, 2026-08-31: "where it says 1 box, can you instead make that text // larger... so the lcient knows." It was .72rem at 75% opacity -- smaller than // the brand's own Small size, and dimmed -- so the one fact a customer most // needs from this panel read as a footnote. How many parcels are coming is a // critical data point, and the type scale has a weight for exactly those. // // Sizes sit underneath at Small and muted: hierarchy from size and colour, // which is what the kit asks for rather than caps or letter-spacing. Numbers // only, so nothing here can carry markup even though it is all our own. // THE ADDRESS LINE ON THE DELIVERY STEP CARRIES NO SUITE. // // Christina, 2026-09-02: "make this so no suite entry is here. but then say // they have to do it on the next page." // // The suite is asked for on the next step, in its own Apt / Suite / Floor // field, and it is the carrier's least reliable input -- a suite typed into // the street line is what UPS rewrites or drops. It also mattered mechanically: // editing the suite in this box counted as changing the address, and the // checkout re-rated (or did not) off the back of it. // // The suite is not discarded, only unshown here: _ckShip.line2 keeps it and // the next step displays and edits it. function _ckAddrLine(a) { a = a || {}; return [a.line1, a.city, a.state, a.zip].filter(Boolean).join(', '); } function _ckBoxNoteHtml(boxes) { var bs = Array.isArray(boxes) ? boxes : []; if (!bs.length) return ''; // Number(0) || 1 is 1, so a box entry saying "none of these" counted as one. // Missing means one; zero means zero. Checked by identity, not coerced. var countOf = function (b) { if (!b || b.count === undefined || b.count === null || b.count === '') return 1; return Number(b.count) || 0; }; var n = bs.reduce(function (t, b) { return t + countOf(b); }, 0); if (!n) return ''; // ONE LINE PER BOX SIZE, NOT ONE SENTENCE FOR ALL OF THEM. // // Christina, 2026-09-02: "also list out the boxes that will need, not the in // one line thing. i'd love thumbnails of cubes with the sizes, weight, and // the qty." On a 22-box order the old line ran to four wrapped rows of // "1 × 24×24×24 in," repeated, which is unreadable and hides the weight // entirely -- the thing that decides whether one person can lift it. // // Identical boxes are collapsed: eighteen 24×24×24s are one row saying 18, // not eighteen rows. Keyed on the dimensions as given, so a 24×24×24 and a // 24×24×24 of different weights stay separate rather than averaging into a // figure that is true of neither. var groups = []; var seen = {}; bs.forEach(function (b) { var l = Number(b.l) || 0, w = Number(b.w) || 0, h = Number(b.h) || 0; var lb = Number(b.grossLb != null ? b.grossLb : b.heaviestLb); var key = [l, w, h, Number.isFinite(lb) ? Math.round(lb * 10) / 10 : 'x'].join('|'); if (seen[key] === undefined) { seen[key] = groups.length; groups.push({ l: l, w: w, h: h, lb: lb, n: 0 }); } groups[seen[key]].n += countOf(b); }); // A cube drawn once and reused. Inline so it needs no network, and sized in // ems so it follows the text rather than fighting it. var cube = ''; var rows = groups.map(function (g) { var dims = [g.l, g.w, g.h].join('×') + ' in'; var wt = Number.isFinite(g.lb) && g.lb > 0 ? (Math.round(g.lb * 10) / 10) + ' lb' : null; return '
    ' + cube + '' + '' + _escHtml(dims) + '' + (wt ? '' + _escHtml(wt) + ' each' : '') + '' + '× ' + g.n + '' + '
    '; }).join(''); return '
    Ships in ' + n + (n === 1 ? ' box' : ' boxes') + '
    ' + '
    ' + rows + '
    '; } // is, and only the address arm is allowed to mention the address. function _ckRateFailure(status, body) { var j = body || {}; // FREIGHT IS AN ANSWER, NOT A FAILURE, AND IT IS NOT A BAD ADDRESS. // // An order needing a box over our 30in limit ships freight, so the server // returns no parcel rates and says so. This is checked FIRST because every // other arm below would otherwise claim it. if (j.freight) { // The one sentence about WHY, read off the server's review so the page, // the receipt and the alert to staff describe the order the same way. // Christina, 2026-09-06: "it should say freight will quote or needs pallet // or something like that, so they can expect a custom charge after // someone reviews." Escaped: `why` is ours, but it goes into innerHTML. var _fwhy = String((j.review && j.review.why) || 'more than we send by parcel') .replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); return { kind: 'freight', text: 'This order ships freight. ' + 'It is ' + _fwhy + ', more than we send by parcel, so we will quote the freight ' + 'after we review your order and confirm the cost with you before it ships. ' + 'It is not included in the total below. Choose Pickup instead if you would ' + 'rather collect it.' }; } if (status === 401 || status === 403) { return { kind: 'signedout', text: 'Your session has ended. Sign in again and the rates will load.' }; } if (status === 503) return { kind: 'unconfigured', text: 'Shipping is not configured yet.' }; if (status === 400) { return { kind: 'noaddress', text: 'That address is missing something UPS needs \u2014 check the postcode and country.' }; } if (status >= 500 || (status !== 200 && !j.rates)) { return { kind: 'unavailable', text: 'We could not reach UPS just now. Try again in a moment.' }; } // Every box can ship; no ONE service carries them all. Splitting the shipment // is a real answer and the customer cannot guess it from silence. // The packer looked and said these parts do not fit anything we can send by // parcel. Not an outage, and not the address. if (j.cannotPack) { return { kind: 'cannotpack', text: 'We cannot work out a parcel for this order ' + '\u2014 it needs packing by hand. Choose Pickup, or contact us and we will quote the shipping.' }; } if (j.noCommonService) { return { kind: 'nocommon', text: 'This order needs ' + (Number(j.parcels) || 2) + ' boxes, and UPS has no single service that carries them all. Choose Pickup, or contact us and we will split the shipment across services.' }; } // THE CARRIER'S OWN REASON BEATS OUR GUESS AT IT. // // "No UPS rates for that address" was said for a package over UPS's size // limit, for an account problem, and for an address it genuinely could not // serve -- three different things, one sentence, and only one of them about // the address. EasyPost tells us which; if it did, that is what is shown. var cm = Array.isArray(j.carrierMessages) ? j.carrierMessages : []; if (cm.length && cm[0] && cm[0].message) { return { kind: 'carrier', text: 'UPS could not rate this shipment: ' + cm[0].message }; } return { kind: 'noaddress', text: 'No UPS rates for that address.' }; } function _ckRateProgress(rows) { var started = Date.now(); var paint = function () { var secs = Math.round((Date.now() - started) / 1000); rows.innerHTML = '
    ' + 'Working out how this packs
    ' + 'Box sizes and weights first, then UPS rates for every box.' + (secs >= 2 ? '
    ' + secs + 's' : '') + '
    '; }; paint(); _ckRateProgressStop(); window._ckRateTick = setInterval(paint, 1000); } function _ckRateProgressStop() { if (window._ckRateTick) { clearInterval(window._ckRateTick); window._ckRateTick = null; } } // HOW OLD A SHOWN RATE MAY BE BEFORE WE ASK AGAIN. // // Deliberately UNDER the server's own plan lifetime (PLAN_TTL_MS, 30 minutes in // lib/packed-plan.mjs): refreshing before that expires means the re-rate can // still use the signed plan the cart was packed on, instead of falling back to // a fresh pack and inviting the very disagreement this exists to prevent. var CK_RATE_TTL_MS = 25 * 60 * 1000; var _ckRatesAt = 0; // RE-ASK IF THE SHOWN PRICE HAS GONE STALE, AND NEVER STAND IN THE WAY. // // Christina, 2026-09-15: "as long as it doesnt affect other things downstream // and prevent payment." // // So every failure path returns the total we already had. A refresh that cannot // reach the server, or throws, or comes back without a usable number, leaves // checkout exactly as it is today -- the guard in initCardPayment still catches // a real disagreement, as it did before this existed. This can only turn a // refusal into a sale; it can never turn a sale into a refusal. async function _ckRefreshQuoteIfStale(fallback) { try { // Nothing to re-ask for: pickup, courier and freight have no carrier rate, // and an unquoted cart has nothing on screen to go stale. if (!ckSelectedRate || ckSelectedRate._freight) return fallback; if (!_ckRatesAt || (Date.now() - _ckRatesAt) < CK_RATE_TTL_MS) return fallback; await fetchCarrierRates(); if (typeof _ckGrandTotalNum !== 'function') return fallback; var next = Number(_ckGrandTotalNum()); if (!isFinite(next) || next <= 0) return fallback; // TELL THEM IF IT MOVED. A total that changes silently while somebody is // looking at it is a worse failure than the one this replaces, even though // it is quieter -- they agreed to a number and would be charged another. if (Math.abs(Math.round(next * 100) - Math.round(Number(fallback || 0) * 100)) > 1) { var m = document.getElementById('ckCardMsg'); if (m) m.textContent = 'Shipping was rechecked after a while on this page, so the total has been updated.'; try { qEvent('checkout_quote_refreshed', { metadata: { from: Number(fallback || 0), to: next } }); } catch (e) {} } return next; } catch (e) { // Deliberately silent: the old total is still the one on screen, and the // mismatch guard is still behind it. return fallback; } } async function fetchCarrierRates() { const _carry = _ckRepointFrom; _ckRepointFrom = null; const _mySeq = ++_ckRatesSeq; // The ticker is one timer for the page. The call this one supersedes will // return without stopping it, and this one may end before starting its own // (no postcode, a freight quote) -- so it is stopped here, or it paints // "working out how this packs" over this call's answer every second. _ckRateProgressStop(); const rows = document.getElementById('ckCarrierRows'); if (!rows) return; if (!document.getElementById('ckAddrCheck')) { const slot = document.createElement('div'); slot.id = 'ckAddrCheck'; rows.parentNode.insertBefore(slot, rows); } if (!_ckAddr) { rows.innerHTML = '
    Enter your address to see UPS rates.
    '; return; } if (!_ckAddrZip && _ckAddr.formatted) { // Was /\b(\d{5})\b/ — five digits, i.e. a US ZIP. That matched nothing in // 'SW1A 1AA' or 'M5V 3L9', so an international address never got as far as // a rate request. var _cc = String(_ckAddr.country || 'US').toUpperCase(); var zm = _cc === 'US' ? _ckAddr.formatted.match(/\b(\d{5})(?:-\d{4})?\b/) : _ckAddr.formatted.match(/\b([A-Z0-9][A-Z0-9\- ]{2,9}[A-Z0-9])\s*$/i); if (zm) _ckAddrZip = zm[1].trim(); } if (!_ckAddrZip) { rows.innerHTML = '
    Pick your address from the suggestions (or include a postal code) to see UPS rates and arrival dates.
    '; return; } // A freight quote never reaches UPS. Even with a valid address there is no // rate to show: parcel pricing for a pallet is wrong, and an empty rate list // would read as "we cannot ship to you" and stop the order. const _fr = _lqFreight(); if (_fr) { const _fesc = (v) => (v == null ? '' : String(v)).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); ckSelectedRate = { carrier: 'Makelab', service: 'Freight', _label: _fr.label, rate: 0, _arrival: null, _freight: true }; ckAddrConfirmed = true; rows.innerHTML = '
    ' + _fesc(_fr.label) + '. ' + 'This order is too large to ship as a parcel, so freight is quoted separately and is ' + 'not included in the total below. We will confirm the cost with you before it ships.' + (_fr.note ? '
    ' + _fesc(_fr.note) : '') + '
    ' + ''; const _cur = document.getElementById('curDelivery'); if (_cur) { _cur.textContent = _fr.label; _cur.classList.remove('empty'); } _ckLastRates = [ckSelectedRate]; renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); return; } // SAY WHAT IS HAPPENING, AND FOR HOW LONG. // // Christina, 2026-08-31: "can you also make it more transparent of a process. // like status updates. like getting box sizes and weights, then getting rates." // // Both steps happen inside ONE request -- the server packs the cart and then // prices every box with UPS -- so this page cannot see the moment the first // ends and the second begins, and a timer pretending to would be inventing // it. So it names the two steps in order and counts the seconds, which is // real: a big cart genuinely takes a few of them, and a panel that sat on // "Getting UPS rates..." looked stuck rather than busy. What actually // happened -- how many boxes, and what size -- lands underneath the moment // the answer arrives. // A packer verdict on the LAST address does not carry to this one. The // placeholder it selected goes with it, so a parcel answer for the new // address cannot arrive with a freight row still selected underneath it. if (_ckPackerFreight) { _ckPackerFreight = null; if (ckSelectedRate && ckSelectedRate._review) ckSelectedRate = null; } _ckRateProgress(rows); // line2 was missing here, so the carrier was never told about the apartment // or suite — which is why its 'corrected' address came back without one, and // why accepting that correction would have quietly dropped it. const to = { line1: _ckAddr.line1 || '', line2: _ckAddr.line2 || '', city: _ckAddr.city || '', state: _ckAddr.state || '', zip: _ckAddrZip, country: (_ckAddr.country || 'US').toUpperCase() }; // EasyPost returns NO international rates without customs, which reads to the // customer as "we don't ship there". One line per part, valued at what they // are paying for it. var _customs = null; if (to.country !== 'US') { try { var _cp = _ckAppliedParts(); _customs = { declaredValue: (typeof _ckGrandTotalNum === 'function') ? Math.round(_ckGrandTotalNum()) : 0, items: _cp.slice(0, 20).map(function (p) { var pr = (typeof calcPartPrice === 'function') ? calcPartPrice(p) : null; return { description: '3D printed part', quantity: Number(p.qty || 1), value: (pr && pr.total) ? Math.max(1, Math.round(pr.total)) : 1, weight: 1 }; }), }; } catch (e) { _customs = null; } } try { const r = await fetch('/api/ship-rates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ to, parts: _cartPartsForPacking(), parcel: _cartParcel(), customs: _customs }, _ckPrepackFor(_cartPartsForPacking()))) }); const j = await r.json(); // THE NEWEST QUESTION GETS THE ANSWER, not the slowest reply. // // Each call quotes the address as it was when it was sent. Typing an // address fires several (six in six seconds on 2026-09-28), and a reply // that lands after a newer one would put a rate and shipment for an // address she has since changed underneath the one on the order -- which // the server cannot see, because it checks the rate against its shipment, // not the shipment against the address. if (_mySeq !== _ckRatesSeq) return; // WHEN THIS ANSWER CAME FROM THE SERVER. // // The total on screen is built on this rate and was never re-asked. On // 2026-09-15 a customer sat on checkout for two hours, pressed pay, and the // server priced the cart fresh: screen 19080 cents, server 21992, payment // refused. The rate was from 20:12 and the attempt was at 22:13. _ckRatesAt = Date.now(); // The answer is here: whatever it says, nothing is in progress any more. _ckRateProgressStop(); // ONE ANSWER PER OUTCOME. Freight, a dead session, a carrier outage, // boxes with no shared service and an address UPS will not serve are five // different things. _ckRateFailure decides which, so the order they are // checked in cannot drift between the two served copies of this page -- // and a failed re-rate never leaves the previous address's rate selected. // FREIGHT IS AN ANSWER THE ORDER GOES AHEAD ON, not a wall. // // Christina, 2026-09-06: "if freight appears then they should enter their // address and then continue. knowing they would be charged more." Until // now this fell into the arm below with every other empty rate list: the // note was drawn, the rate was nulled, and the Continue button read // 'Select a shipping speed' for a speed that did not exist -- a dead end // for exactly the orders worth the most. // // So it takes the same path a staff quote marked pallet/crate takes: a // zero-rate placeholder flagged _freight, which every surface downstream // already renders as TBD, prices at $0 and posts as 'Freight — to be // quoted'. The server decides the charge on its own reading of the cart // (lib/server-price.mjs), so nothing here is trusted; this only lets the // customer through with a confirmed address. if (r.ok && j.freight) { _ckPackerFreight = j.review || { why: j.why || 'more than we send by parcel', rule: null }; _ckShipmentId = null; _ckPlanBoxes = null; ckSelectedRate = { carrier: 'Makelab', service: 'Freight', _label: 'Freight', rate: 0, _arrival: null, _freight: true, _review: _ckPackerFreight, _shippingService: 'Freight — to be quoted' }; ckAddrConfirmed = true; rows.innerHTML = '
    ' + _ckRateFailure(r.status, j).text + '
    ' + ''; (function () { const note = document.getElementById('ckBoxNote'); if (note) note.textContent = ''; })(); _ckPaintFreightRows(); _ckLastRates = [ckSelectedRate]; renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); return; } if (!r.ok || !j.rates || !j.rates.length) { rows.innerHTML = `
    ${_ckRateFailure(r.status, j).text}
    `; ckSelectedRate = null; return; } _ckShipmentId = j.shipmentId || null; // The plan this price was built on, kept beside the shipment id because it // is the same kind of fact: what this rate fetch produced. Checkout posts it // with the order so dispatch packs and labels against the SAME boxes. _ckPlanBoxes = j.planBoxes || null; // A parcel answer after a freight one: bring the courier row back and put // 'Shipping' back on the shipping row. _ckPaintFreightRows(); // WHAT IT IS ACTUALLY GOING IN. Christina, 2026-08-30: "can the shipping // rates show how many boxes would be needed? and what size?" // // A price for eleven parcels reads as a mistake next to a price for one, and // there was nothing on screen to say which it was. Sizes as well as a count, // because "3 boxes" invites the next question. (function () { const note = document.getElementById('ckBoxNote'); if (!note) return; const bs = j.planBoxes || []; if (!bs.length) { note.textContent = ''; return; } // SO THE CUSTOMER KNOWS WHAT IS TURNING UP. // // Christina, 2026-08-31: "where it says 1 box, can you instead make that // text larger... so the lcient knows." It was .72rem at 75% opacity -- // smaller than the brand's own Small size and dimmed -- so the one fact a // customer most needs from this panel read as a footnote. How many // parcels are coming is a critical data point, and the type scale has a // weight for those. // // Sizes stay underneath at Small and muted: hierarchy by size and colour, // which is what the kit asks for. Numbers only, so nothing here can carry // markup even though it is all our own. note.innerHTML = _ckBoxNoteHtml(bs); })(); _ckShowAddressCheck(j.address, to); // Terms are DDU/DAP — the recipient settles duties and import VAT on // delivery. Say so before they pick a rate, not after the parcel is held. window._ckIntlOrder = !!j.international; const ready = (ckLeadChosen ? addBusinessDays(productionStart(), _ckSelectedTier().businessDays) : productionStart()); // Soonest first, then cheapest (backlog, 2026-08-01). Sorted BEFORE the // slice — taking six and then ordering them only sorts an arbitrary six, // and the fastest service is exactly the one that gets cut. // // A rate with no transit time goes last whatever it costs: UPS returns no // deliveryDays on most international services, and an option we cannot put // a date on should not sit above ones we can. const _byArrivalThenPrice = (a, b) => { const da = a.deliveryDays || Infinity, db = b.deliveryDays || Infinity; if (da !== db) return da - db; return (Number(a.rate) || 0) - (Number(b.rate) || 0); }; const rates = j.rates.slice().sort(_byArrivalThenPrice).slice(0, 6).map(rt => { const arr = rt.deliveryDays ? addBusinessDays(ready, rt.deliveryDays) : null; return Object.assign({}, rt, { _arrival: arr, _label: _upsServiceName(rt.service) }); }); if (window._ownLabelEnabled) rates.push({ carrier: 'UPS', service: 'OwnLabel', _label: 'Provide your own UPS label', rate: 0, _arrival: null, _ownLabel: true }); const _dutyNote = j.international ? '
    Import duties and taxes are not included — the carrier collects them from you on delivery.
    ' : ''; rows.innerHTML = _dutyNote + rates.map((rt, i) => ``).join(''); rows.querySelectorAll('.ck-row').forEach(row => { row.addEventListener('click', () => { ckSelectedRate = rates[parseInt(row.dataset.rate, 10)]; ckAddrConfirmed = true; const cur = document.getElementById('curDelivery'); if (cur) { cur.textContent = ckSelectedRate._label; cur.classList.remove('empty'); } rows.querySelectorAll('.ck-row').forEach(r => r.classList.remove('active')); row.classList.add('active'); renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); }); }); // Re-point the selection at THIS shipment's copy of the chosen service, or // clear it. Done after the rows are wired so the highlight and the choice // line agree with the id that will actually be sent. // Nothing selected but a choice carried in: an address changed on Billing & // Shipping, and the service chosen for the old one is looked for here. const _prevSel = ckSelectedRate || _carry; const _repointed = _ckRepointSelectedRate(_prevSel, rates); if (_prevSel && !_prevSel._ownLabel && !_prevSel._freight) { const _cur = document.getElementById('curDelivery'); if (_repointed) { ckSelectedRate = _repointed; const _idx = rates.indexOf(_repointed); rows.querySelectorAll('.ck-row').forEach(function (r) { r.classList.toggle('active', parseInt(r.dataset.rate, 10) === _idx); }); if (_cur) { _cur.textContent = _repointed._label; _cur.classList.remove('empty'); } } else { // The service they picked is not on the new list. Say so NOW. ckSelectedRate = null; rows.querySelectorAll('.ck-row').forEach(function (r) { r.classList.remove('active'); }); if (_cur) { _cur.textContent = 'Re-select a shipping option'; _cur.classList.add('empty'); } try { qEvent('ship_rate_reselect', { metadata: { reason: 'service_gone_after_refetch' } }); } catch (e) {} } try { renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); } catch (e) {} } _ckLastRates = rates; // Rates are on screen and wired. Only now can a deadline typed earlier be // answered across production AND shipping — and it must run after this, not // before, or it marks rows that are about to be replaced. try { _resolveNeedByWithShipping(rates); } catch (e) {} } catch (e) { if (_mySeq !== _ckRatesSeq) return; _ckRateProgressStop(); rows.innerHTML = '
    Could not fetch rates. Try again.
    '; } } // ── Accordion open/close control ── function ckAccs() { return Array.prototype.slice.call(document.querySelectorAll('#checkoutScreen .ck-acc')); } function ckOpenOnly(which) { ckAccs().forEach(a => a.classList.toggle('open', a.dataset.acc === which)); } function ckCloseAll() { ckAccs().forEach(a => a.classList.remove('open')); } function ckOpen(which) { const a = ckAccs().filter(function (x) { return x.dataset.acc === which; })[0]; if (a) a.classList.add('open'); } function updateAccDone() { ckAccs().forEach(function (a) { var done = a.dataset.acc === 'lead' ? ((typeof ckLeadChosen !== 'undefined' && ckLeadChosen) || (typeof _leadRfq === 'function' && _leadRfq())) : a.dataset.acc === 'delivery' ? (typeof ckDispatchChosen !== 'undefined' && ckDispatchChosen) : false; a.classList.toggle('ck-acc-done', !!done); }); } function wireAccordionHeads() { ckAccs().forEach(a => { const head = a.querySelector('.ck-acc-head'); if (head && !head._ckWired) { head._ckWired = true; head.addEventListener('click', () => a.classList.toggle('open')); } }); } // kept for compatibility — older code may call renderDispatch(); now a no-op shim. function renderDispatch() { renderDeliveryRows(); } // kept for compatibility — parcel builder still used by carrier rates. function _readyDate() { const tier = DELIVERY_TIERS.find(t => t.key === orderDeliveryTier) || DELIVERY_TIERS.find(t => t.key === 'standard'); return tier ? addBusinessDays(productionStart(), tier.businessDays) : null; } function renderCheckout() { // A confirmation from the previous order is laid over the checkout — take it // off, so ordering twice in a row does not need a page reload. try { _ckClearConfirm(); } catch (e) {} try { _ckEmptySpentCart(); } catch (e) {} // Set when an order completes and never cleared, so a second order lost its // order-by countdown and its delivery caveat. try { window._orderPlaced = false; } catch (e) {} // Leaving the configurator: deactivate any viewer tool + close the tools menu. try { if (typeof setTool === 'function') setTool(null); var _th = document.getElementById('toolbarHost'); if (_th) _th.classList.remove('open'); var _tt = document.getElementById('toolbarTrigger'); if (_tt) _tt.classList.remove('open'); } catch (e) {} // Fresh entry always lands on step 2 — never resume a stale step-3 view (e.g. the // RFQ page from a cart that was edited and no longer needs an RFQ, or vice versa). _ckStep = 2; _ckStep3Mode = 'pay'; { const _s2 = document.getElementById('ckStep2'), _s3 = document.getElementById('ckStep3'); if (_s2) _s2.style.display = ''; if (_s3) _s3.style.display = 'none'; } if (typeof setCrumbs === 'function') setCrumbs(2); { const _back = document.getElementById('checkoutBack'); if (_back) _back.innerHTML = '\u2190 Back to Configurator'; } // reset per-open selection state so nothing STALE is pre-selected ckLeadChosen = false; ckDispatchChosen = false; _rfqDeliveryOpened = false; ckSelectedRate = null; ckAddrConfirmed = false; ckPickupConfirmed = false; _ckAddr = null; _ckAddrZip = null; // A code the customer typed is not stale state — it is something they entered // and often earned, and going back to change a part should not quietly take it // away. The APPROVAL is dropped and re-earned: whether a code qualifies depends // on the subtotal and on whether the order is sitting at a technology minimum, // and the cart is exactly what they went back to change. // Auto-offered vouchers are not remembered — they are re-offered on entry and // re-derived from the cart as it is now. if (ckDiscount && !ckDiscountAuto) _ckTypedCode = ckDiscount.code; ckDiscount = null; ckDiscountMsg = null; ckDiscountAuto = false; _ckUseCredit = true; ckCcEmails = []; ckBillingSameAsShip = true; _ckBillingAddr = null; const cl = document.getElementById('curLead'); if (cl) { cl.textContent = 'Choose'; cl.classList.add('empty'); } const cd2 = document.getElementById('curDelivery'); if (cd2) { cd2.textContent = 'Choose'; cd2.classList.add('empty'); } const dd = document.getElementById('ckDeliveryDetail'); if (dd) dd.innerHTML = ''; // The customer's saved defaults are a legitimate pre-selection — applied after // the reset above and before the rows render, so they paint as chosen. try { applyLogisticsDefaults({ silent: true }); } catch (e) {} try { _ckRestoreDiscount(); } catch (e) {} try { _ckFetchCreditBalance(); } catch (e) {} ckOpenOnly(ckLeadChosen && !ckDispatchChosen ? 'delivery' : 'lead'); ensureValidTier(); renderLeadRows(); renderDeliveryRows(); renderDiscountField(); { const _nb = document.getElementById('ckNeedBy'); if (_nb && !_nb._wired) { _nb._wired = true; _nb.addEventListener('change', _applyNeedBy); _nb.addEventListener('input', _applyNeedBy); } // A date from a previous order is not this order's deadline. if (_nb) { _nb.value = ''; const _m = document.getElementById('ckNeedByMsg'); if (_m) { _m.className = ''; _m.textContent = ''; } const _h = document.getElementById('ckNeedByHint'); if (_h) _h.style.display = ''; } // Nothing before today is a deadline anyone can act on. if (_nb) { const _t = new Date(); _nb.min = _t.getFullYear() + '-' + String(_t.getMonth() + 1).padStart(2, '0') + '-' + String(_t.getDate()).padStart(2, '0'); } } wireAccordionHeads(); wireCarrierAddress(); renderCheckoutTimeline(); renderCheckoutSummary(); updateContinueState(); startCheckoutCountdown(); // sync header controls with global state when checkout opens document.querySelectorAll('#ckUnitPill .env-unit-btn').forEach(b => b.classList.toggle('active', b.dataset.unit === viewUnit)); const _mainTog = document.getElementById('themeToggle'), _ckTog = document.getElementById('ckThemeToggle'); if (_mainTog && _ckTog) { _ckTog.checked = _mainTog.checked; const _ckLbl = document.getElementById('ckThemeLabel'); if (_ckLbl) { _ckLbl.textContent = _mainTog.checked ? 'Dark' : 'Light'; _ckLbl.style.color = _mainTog.checked ? 'rgba(255,255,255,0.7)' : '#555'; } const _ckWrap = document.getElementById('ckThemeWrap'); if (_ckWrap) { if(_mainTog.checked){ _ckWrap.style.background='rgba(40,40,40,0.94)'; _ckWrap.style.borderColor='rgba(255,255,255,0.12)'; } else { _ckWrap.style.background='rgba(255,255,255,0.9)'; _ckWrap.style.borderColor='rgba(0,0,0,0.08)'; } } } } // ── Cutoff countdown (live HH:MM:SS to next business-day 3:30pm ET) ── let _ckCountdownTimer = null; function startCheckoutCountdown() { if (_ckCountdownTimer) return; function etNow() { return new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' })); } function iso(x) { return x.getFullYear() + '-' + String(x.getMonth() + 1).padStart(2, '0') + '-' + String(x.getDate()).padStart(2, '0'); } function biz(x) { const g = x.getDay(); return g !== 0 && g !== 6 && !HOLIDAYS.has(iso(x)); } let _prevCut = ''; function tick() { const n = etNow(); let c = new Date(n); c.setHours(15, 30, 0, 0); while (c <= n || !biz(c)) { c.setDate(c.getDate() + 1); c.setHours(15, 30, 0, 0); } let _lqTarget = false; const _lqcd = (typeof _lqCommittedDate === 'function') ? _lqCommittedDate() : null; if (_lqcd && _lqcd.holds && _lqcd.orderByDate) { const pp = _lqcd.orderByDate.split('-'); const oc = new Date(Number(pp[0]), Number(pp[1]) - 1, Number(pp[2]), 15, 30, 0, 0); if (oc > n) { c = oc; _lqTarget = true; } } const d = c - n, h = Math.floor(d / 3.6e6), m = Math.floor(d % 3.6e6 / 6e4), ss = Math.floor(d % 6e4 / 1e3); const p = x => (x < 10 ? '0' : '') + x; const days = Math.floor(h / 24); const txt = (h >= 24) ? (days + (days === 1 ? ' day ' : ' days ') + (h % 24) + 'h ' + p(m) + 'm') : (p(h) + ':' + p(m) + ':' + p(ss)); document.querySelectorAll('.ck-cd').forEach(function (e) { e.textContent = txt; }); const t0 = new Date(n); t0.setHours(0, 0, 0, 0); const c0 = new Date(c); c0.setHours(0, 0, 0, 0); const dayTxt = (c0.getTime() === t0.getTime()) ? 'today' : (_lqTarget ? c.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : c.toLocaleDateString('en-US', { weekday: 'long' })); document.querySelectorAll('.ck-cdday').forEach(function (e) { e.textContent = dayTxt; }); const cut = iso(c); if (_prevCut && _prevCut !== cut) { try { renderCheckoutTimeline(); renderCheckoutSummary(); if (_ckStep === 3 && _ckStep3Mode !== 'rfq' && typeof renderPaymentStep === 'function') renderPaymentStep(); } catch (e) {} } _prevCut = cut; } tick(); _ckCountdownTimer = setInterval(tick, 1000); } function _unconfiguredParts() { return parts.filter(function (p) { return !p._applied || calcAppliedPrice(p).unset; }); } document.getElementById('quoteBtn').addEventListener('click', () => { if (parts.length === 0) return; const _ab = document.getElementById('applyQuoteBtn'); if (_ab && _ab.style.display !== 'none') applySelected(); if (!parts.some(p => p._applied && !calcAppliedPrice(p).unset)) return; const skipped = _unconfiguredParts(); if (skipped.length) { _showExcludedModal(skipped); return; } openCheckout(); }); // Names the parts that will not travel, and makes continuing an explicit choice. function _showExcludedModal(skipped) { var existing = document.getElementById('mlqExcluded'); if (existing) existing.remove(); var wrap = document.createElement('div'); wrap.id = 'mlqExcluded'; wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483600;display:flex;align-items:center;justify-content:center;background:rgba(37,46,68,.55);font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;'; var list = skipped.map(function (p) { return '
  • ' + _escHtml(p.name || 'Untitled part') + '
  • '; }).join(''); wrap.innerHTML = '
    ' + '
    Some parts have no options chosen
    ' + '
    ' + '

    ' + (skipped.length === 1 ? 'This part has no material or technology selected, so it cannot be priced and will not be included:' : 'These parts have no material or technology selected, so they cannot be priced and will not be included:') + '

    ' + '' + '

    Go back to finish configuring them, or continue without them.

    ' + '
    ' + '' + '' + '
    ' + '
    '; document.body.appendChild(wrap); var close = function () { try { wrap.remove(); } catch (e) {} }; document.getElementById('mlqExclBack').addEventListener('click', function () { close(); // Put them in front of the person who has to fix them. try { if (skipped[0]) { selectedIds = new Set([skipped[0].id]); if (typeof renderPartsList === 'function') renderPartsList(); if (typeof renderSelectedPart === 'function') renderSelectedPart(); } } catch (e) {} }); document.getElementById('mlqExclGo').addEventListener('click', function () { close(); openCheckout(); }); wrap.addEventListener('mousedown', function (e) { if (e.target === wrap) close(); }); document.addEventListener('keydown', function onEsc(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onEsc); } }); } { const _ab = document.getElementById('applyQuoteBtn'); if (_ab) _ab.addEventListener('click', function () { applySelected(); if (window.__collapseAccordion) window.__collapseAccordion(); }); } { const _vw = document.getElementById('viewWarnToggleBtn'); if (_vw) { _vw.classList.toggle('on', !WARNINGS_HIDDEN); _vw.addEventListener('click', function () { setWarningsHidden(!WARNINGS_HIDDEN); }); } } // ── Dispatch method (pickup / courier / shipping via EasyPost) ── let orderDispatch = 'pickup'; const PICKUP_LOCATION = '13 42nd St, Brooklyn, NY'; // WHAT THE PACKER NEEDS, STRAIGHT OFF THE CART. // // Christina, 2026-08-30: "customer should be quoted against the calcualtion of // the weight and stuff." _cartParcel below works out ONE parcel here in the // page -- max X by max Y by the SUM of Z, each line counted once, no box, no // board, no void fill -- and the rate is quoted against whatever it produces. // This sends the cart instead and lets the packer answer, server side, from the // real box catalogue. // // _cartParcel is still sent alongside, and is still what the server falls back // to if it cannot reach the packer. Removing it would turn a packer outage into // a checkout that cannot quote at all. function _cartPartsForPacking() { // WHAT WAS APPLIED, NOT THE SIDEBAR. _payCartPayload charges on appliedView(p); // packing the live sidebar showed one weight and charged another when a part // was edited and not applied (found by replay 2026-09-22 once infill counted). return parts.filter(p => p._applied && !calcAppliedPrice(p).unset).map(p => { const v = appliedView(p) || p; return { id: p.id || p.uploadId || null, name: p.name || p.fileName || null, qty: v.qty || 1, bboxRaw: p.bboxRaw, scaleVec: p.scaleVec, displayUnit: p.displayUnit || 'mm', volumeCm3: p.volumeCm3 || 0, // THE PACKER CAN ONLY NEST A PART IT KNOWS THE SURFACE OF. // // lib/cart-parcels.mjs planLine turns this into surface_area, which the // production packer reads to tell a curved shell from a solid (src/lib/ // nesting.js). This list never carried it, so every checkout packed with // nesting off: Christina, 2026-09-21, "nesting does not seem live" -- order // 11701's four fender trims packed into 1 box on the Packing tab and were // quoted as 4 at checkout. Already scaled, like volumeCm3. surfaceCm2: p.surfaceCm2, material: v.material || null, tech: v.tech || null, // The page holds infillDensity; p.infill_density was always undefined, so // the packer weighed every FDM part solid. lib/cart-parcels.mjs planLine // reads either spelling. infillDensity: v.infillDensity, }; }); } function _cartParcel() { const applied = parts.filter(p => p._applied && !calcAppliedPrice(p).unset); // bboxRaw and scaleVec come in TWO SHAPES -- {x,y,z} and [x,y,z]. Reading // only .x meant `undefined * undefined` on every array-shaped part, so L/W/H // went NaN and JSON.stringify sent them as null: 28% of orders asked the // carrier to rate a parcel with no dimensions at all, weight only, losing // dimensional pricing entirely. lib/stored-geometry.mjs already reads both // shapes (sv.x ?? sv[0] ?? 1); this did not. Verified on order 11634, whose // bboxRaw is [288.77,113.411,125.446]. const _ax = (v, i, dflt) => { if (v == null) return dflt; const n = Number(Array.isArray(v) ? v[i] : v[['x', 'y', 'z'][i]]); return Number.isFinite(n) ? n : dflt; }; let L = 0, W = 0, H = 0, vol = 0; applied.forEach(p => { const u = p.displayUnit || 'mm'; const k = u === 'in' ? 25.4 : u === 'cm' ? 10 : 1; const sv = p.scaleVec; // QUANTITY COUNTS HERE TOO. // // The height was summed once per LINE, so a thousand of a part was exactly // as tall as one of it while the weight below already scaled by qty -- a // small box holding a heavy order, quoted cheap. Christina, 2026-08-31: // "i put in 1000 of a part ... it instead gave me no boxes and gave me a // super cheap shipping price." // // A stack is still a poor model of packing -- the packer is what gets this // right, and this runs only when the packer could not answer at all. But it // is now wrong in the direction that gets NOTICED: a thousand-high stack // exceeds what UPS will carry, so the carrier declines and the customer is // told, instead of being quoted a parcel that does not hold their order. const _q = Math.max(1, Number(p.qty) || 1); L = Math.max(L, Math.abs(_ax(p.bboxRaw, 0, 0) * _ax(sv, 0, 1) * k)); W = Math.max(W, Math.abs(_ax(p.bboxRaw, 1, 0) * _ax(sv, 1, 1) * k)); H += Math.abs(_ax(p.bboxRaw, 2, 0) * _ax(sv, 2, 1) * k) * _q; vol += (p.volumeCm3 || 0) * _q; }); // No NaN guard here on purpose: _ax already defaults an unreadable axis to 0, // so mm is always a real number by this point. A second guard tested green // with it deleted, which means it was decoration. const inOf = mm => Math.max(1, Math.ceil(mm / 25.4) + 1); const weightOz = Math.max(4, Math.ceil(vol * 1.2 / 28.35)); return { length: inOf(L), width: inOf(W), height: inOf(H), weight: weightOz }; } document.getElementById('checkoutBack').addEventListener('click', () => { if (_ckStep === 4) backToBillingSub(); else if (_ckStep === 3) backToLeadStep(); else closeCheckout(); }); { const _cr = document.getElementById('ckCrumbs'); if (_cr) _cr.addEventListener('click', function (e) { const sp = e.target.closest && e.target.closest('span[data-step]'); if (!sp || !sp.classList.contains('clickable')) return; const t = +sp.dataset.step; if (t === 1) closeCheckout(); else if (t === 2) { if (_ckStep === 4) backToBillingSub(); backToLeadStep(); } else if (t === 3) { if (_ckStep === 4) backToBillingSub(); } }); } { const _bb = document.getElementById('checkoutBackBottom'); if (_bb) _bb.addEventListener('click', function () { if (_ckStep === 4) backToBillingSub(); else if (_ckStep === 3) backToLeadStep(); else closeCheckout(); }); } { const _pb2 = document.getElementById('ckDownloadPdf2'); if (_pb2) _pb2.addEventListener('click', saveQuoteAndDownload); } { const _pb = document.getElementById('ckDownloadPdf'); if (_pb) _pb.addEventListener('click', saveQuoteAndDownload); } document.getElementById('checkoutSubmit').addEventListener('click', () => { if (typeof ckLeadChosen !== 'undefined' && !ckLeadChosen && !(typeof _leadRfq === 'function' && _leadRfq())) { if (typeof showQuoterToast === 'function') showQuoterToast('Pick a lead time first.'); ckOpenOnly('lead'); return; } // RFQ-required carts have no selectable lead time — the quote confirms it if (typeof ckDispatchChosen !== 'undefined' && !ckDispatchChosen) { if (typeof showQuoterToast === 'function') showQuoterToast('Choose how you\u2019d like to receive your order.'); ckOpenOnly('delivery'); return; } if (orderDispatch === 'shipping' && !ckSelectedRate) { if (typeof showQuoterToast === 'function') showQuoterToast('Pick a UPS carrier service.'); ckOpenOnly('delivery'); return; } if (typeof cartNeedsRfq === 'function' && cartNeedsRfq()) { goToRfqStep(); return; } goToPaymentStep(); }); // ── Step 3: Payment ──────────────────────────────────────────────── let _ckStep = 2; let _ckStep3Mode = 'pay'; try { if (new URLSearchParams(location.search).get('sim') === '1') window._sim = true; } catch (e) {} // Ask the server which payment mode applies to THIS signed-in identity. A test // actor without sandbox keys -> 'simulated' (no real money); real customers -> 'live'. window._payModeReady = null; function _ensurePayMode() { // Cache only a SUCCESSFUL answer. The parse-time call runs before the session // cookie is validated (401 for a beat) — caching that failure permanently hid // the simulated test-checkout for test actors and left broken card/PayPal 503s. if (window._payModeReady) return window._payModeReady; const p = fetch('/api/pay-mode').then(function (r) { return r.ok ? r.json() : null; }).then(function (j) { if (j && j.mode) { window._payMode = j.mode; if (j.mode === 'simulated') window._sim = true; // Whether this company may be invoiced instead of charged. The SERVER // decides (place-order re-resolves it from the company record); this is // only what the checkout is allowed to offer. window._invoiceOpt = j.invoice || null; // A partner-billed client pays nobody here: BluEdge invoice them directly // and settle with us. The payment choices are replaced by one button that // names who is billing them, so an empty payment section never reads as a // broken checkout (Christina, 2026-08-10). window._partnerBilling = j.partner || null; // Which ways this account may pay. The SERVER decides (allowedMethods, // enforced again in place-order); this is only what the checkout may // offer. pay-mode already strips bank_transfer when the bank details are // not configured, so a button is never shown for a method that would 503. window._payMethods = Array.isArray(j.methods) ? j.methods : null; // Whether this account's orders must carry a PO number (a client group // in production with "Requires purchase order" ticked). The SERVER // decides -- place-order, payment-intent and paypal-order refuse without // one; this is only what the box says and what the gate waits for. window._poRequired = !!j.requiresPo; // A BLOCKED ACCOUNT (2026-10-02): pay-mode says so before anyone types a // card; the pay gate below keeps every way to pay off while it stands. window._customerBlocked = j.blocked ? { detail: j.detail || null, staffActing: !!j.staffActing, reason: j.reason || null } : null; return j; } window._payModeReady = null; // failed/unauthed -> allow a later retry return null; }).catch(function () { window._payModeReady = null; return null; }); window._payModeReady = p; return p; } try { _ensurePayMode(); } catch (e) {} let _ckPaypalInited = false, _ckPaypalGrand = 0; // The total on the screen right now, in dollars. Sent with every checkout so the // server can refuse to book a number the customer was never shown -- see the // total_mismatch branch in api/place-order.mjs. let _ckShownGrand = null; // { terms, plain } -- see renderPaymentStep. Null until the payment step is drawn. let _ckShownTotals = null; // WHICH PAGE POSTED, sent with every checkout payload. place-order writes it on // a refused total (checkout_total_refused), so a refusal from a page cached // before it could re-ask is told apart from one this page drew and asked // about. Change it when what this page sends, or how it answers a refusal, // changes. var _CK_PAGE_BUILD = 'quoter/2026-10-01-count-every-piece'; // THE CUSTOMER'S ANSWER WHEN THE SERVER ASKS AGAIN -- public/checkout-reask.js, // the same file accept.html loads. It holds the one rule that matters here: a // total the server asks about is drawn, and sent only after the customer // presses the button under it. Null if that file did not load, and every // caller then says what it said before. function _ckMakeReask() { var R = (typeof window !== 'undefined' && window.MLQ_REASK) || null; if (!R || typeof R.create !== 'function') return null; return R.create({ buttonClass: 'ck-btn ck-btn-dark', buttonStyle: 'width:100%;margin-top:8px', // On the session, beside checkout_total_mismatch: shown, confirmed and // exhausted are how often we asked, how often they said yes, and who we // lost. event: function (name, meta) { try { qEvent(name, { metadata: meta }); } catch (e) {} }, report: function (err) { try { window.mlqReportError && window.mlqReportError(err, { component: 'checkout-reask' }); } catch (e) {} }, // Whose checkout this is, as /api/pay-mode answered (_ensurePayMode): a // give-up on one of our own accounts is written down, not posted to #bugs. payMode: function () { return window._payMode; }, }); } var _ckReask = _ckMakeReask(); // The shown total for the method being placed: the invoicing fee is charged on // terms and on nothing else (api/place-order.mjs), so only terms sends it. function _ckShownCentsFor(method) { var t = (typeof _ckShownTotals !== 'undefined' && _ckShownTotals) || null; var v = t ? (method === 'terms' ? t.terms : t.plain) : ((typeof _ckShownGrand !== 'undefined') ? _ckShownGrand : null); var c = v != null ? Math.round(Number(v) * 100) : null; // UNLESS THE SERVER ASKED AND THE CUSTOMER SAID YES. The figure they // confirmed stands in for this one while the page still shows what it showed // when they did; a cart that has moved since gets its own figure back, and // the server asks again if that one is wrong too. typeof-guarded because // this is lifted into a vm by several harnesses. var r = (typeof _ckReask !== 'undefined' && _ckReask && typeof _ckReask.shown === 'function') ? _ckReask : null; return (c != null && r) ? r.shown(method, c) : c; } // ── Discount codes ───────────────────────────────────────────────── // Store credit: money already on the account, applied AFTER tax as tender - // never as a discount, which would shrink the taxable base. The balance is // fetched on checkout entry; the split below mirrors lib/credits.mjs // creditToApply exactly, because the server creates the charge from the same // rule and the two must land on the same number. let _ckCreditBalance = 0; // cents // Whether this account pays sales tax. Declared with var, not let: the totals // above sit earlier in this file than this line, and an undefined read is // falsy -- which CHARGES tax. Failing the other way would show a total we then // exceed on the card. var _ckTaxExempt = false; let _ckUseCredit = true; // the portal promises 'applied automatically' // A free-shipping credit on the account: { id, label } or null. One-shot - // the order that uses it consumes it. let _ckShipCredit = null; function _ckShipCreditOn() { return !!(_ckShipCredit && ckDispatchChosen && (orderDispatch === 'shipping' || orderDispatch === 'courier')); } // THE delivery charge. Six hand-copies of this expression had already been // written; the free-shipping credit would have meant editing all six. // How a staff quote said this has to ship, when it is not an ordinary parcel. // Freight cannot be priced from a box size, so the quote is issued saying so // (production QuoteEditor -> config.issued.shipConstraint) and checkout must // not offer parcel rates against it. It must also not BLOCK the order -- // shipping is quoted separately and billed later (Christina 2026-08-04). const _FREIGHT_LABELS = { pallet: 'Palletised freight', crate: 'Crated freight', custom: 'Special handling' }; function _lqFreight() { try { const iss = (window.__lockedQuote && window.__lockedQuote.issued) || null; const key = String((iss && iss.shipConstraint) || '').toLowerCase(); if (!key || key === 'standard') return null; return { key, label: _FREIGHT_LABELS[key] || 'Special handling', note: (iss && iss.shipNote) || '' }; } catch (e) { return null; } } // EITHER KIND OF FREIGHT, in the one shape the delivery rows and the Continue // gate read: { key, label, note }. A staff quote marked pallet/crate wins; // otherwise the packer's verdict on this cart at this address, if it gave one. // _lqFreight() stays what it is -- the rate fetch and the detail pane use it to // skip the carrier entirely, which a packer verdict must NOT do: the next // address has to be asked about afresh. function _ckFreight() { const lq = _lqFreight(); if (lq) return lq; if (_ckPackerFreight) return { key: 'review', label: 'Freight', note: '', review: _ckPackerFreight }; return null; } // The delivery rows are drawn before the packer has looked at the cart, so a // verdict has to be painted onto them afterwards -- in place, and NOT via // renderDeliveryRows(), whose tail re-renders the detail pane, which fetches // the rates, which would call this again. A courier cannot take sixty boxes, // so the row goes; the shipping row says what it now is. function _ckPaintFreightRows() { const wrap = document.getElementById('ckDeliveryRows'); if (!wrap) return; const fr = _ckFreight(); const courier = wrap.querySelector('.ck-row[data-method="courier"]'); if (courier) courier.style.display = fr ? 'none' : ''; const ship = wrap.querySelector('.ck-row[data-method="shipping"]'); if (ship) { const t = ship.querySelector('.ck-row-title'), s = ship.querySelector('.ck-row-sub'), p = ship.querySelector('.ck-row-price'); if (t) t.textContent = fr ? fr.label : 'Shipping'; if (s) s.textContent = fr ? 'quoted separately' : 'UPS · exact arrival'; if (p) p.textContent = fr ? 'TBD' : 'from $6'; } const cur = document.getElementById('curDelivery'); if (cur && fr && orderDispatch === 'shipping') { cur.textContent = fr.label; cur.classList.remove('empty'); } } // A carrier service name -> the rung both apps use. // // MIRRORS makelab-core/shipping rungOfService. The browser cannot import it -- // this page is plain HTML, not a bundle -- so tests/discount-parity.test.mjs // lifts this function and runs it against core's over every spelling EasyPost // uses, and they must agree. Same arrangement as the discount maths above. function _ckRungOfService(service) { const n = String(service || '').toLowerCase().replace(/[^a-z0-9]/g, ''); if (!n) return null; if (n.includes('groundsaver') || n.includes('surepost')) return 'groundsaver'; if (n === 'ground' || n.endsWith('ground')) return 'ground'; if (n.includes('3dayselect') || n.includes('3day')) return '3dayselect'; if (n.includes('2nddayairam') || n.includes('2ndam') || /2(nd)?dayam/.test(n)) return '2nddayam'; if (n.includes('2ndday') || n.includes('2day')) return '2ndday'; if (n.includes('nextdayairearly') || n.includes('earlyam') || n.includes('nextdayearly')) return 'nextdayearly'; if (n.includes('nextdayairsaver') || n.includes('nextdaysaver')) return 'nextdaysaver'; if (n.includes('nextdayair') || n.includes('nextday')) return 'nextday'; return null; } // What the customer is actually getting, in the words a voucher is written in. // Null whenever we cannot name it, and null is never covered -- the same rule // the server applies to the rate EasyPost verified. function _ckDeliveryKey() { if (!ckDispatchChosen) return null; if (orderDispatch === 'courier') return 'courier'; if (orderDispatch === 'shipping' && ckSelectedRate) return _ckRungOfService(ckSelectedRate.service); return null; } // A free-shipping VOUCHER, shown the way the free-shipping credit already is. function _ckShipVoucherOn() { if (!ckDiscount || ckDiscount.kind !== 'free_shipping') return false; const key = _ckDeliveryKey(); if (!key) return false; const scope = ckDiscount.shippingAppliesTo; if (!Array.isArray(scope) || !scope.length) return true; // any delivery return scope.map(String).indexOf(String(key)) >= 0; } function _ckShipCost() { if (_ckShipCreditOn()) return 0; if (_ckShipVoucherOn()) return 0; return (ckDispatchChosen && orderDispatch === 'shipping' && ckSelectedRate) ? ckSelectedRate.rate : ((ckDispatchChosen && orderDispatch === 'courier') ? 15 : 0); } function _ckCreditOn() { return _ckUseCredit && _ckCreditBalance > 0; } function _ckCreditSplit(totalCents) { const bal = Math.max(0, Math.floor(_ckCreditBalance)); const total = Math.max(0, Math.floor(totalCents)); let applied = Math.min(bal, total); const remainder = total - applied; // Stripe cannot charge under 50 cents; the same gap rule as the server. if (remainder > 0 && remainder < 50) applied = bal >= total ? total : Math.max(0, total - 50); return { appliedCents: applied, remainderCents: total - applied }; } // Checkout draws its own totals, so it has to know about exemption or it shows // a tax line the card is never charged for -- an exempt customer was quoted // 8.878% on screen and charged zero (Christina 2026-08-04). The server answers // this on /api/credit-balance, resolved exactly as the payment routes resolve // it, so what is shown is what is charged. Exemption is a zero rate rather than // a branch, so every total stays one expression. // // 8.875% (Christina, 2026-09-29: "Tax rate = 8.875% (was 8.878%), new orders // only"), in whole cents rounded half up, worked in integers: 8875 / 100000 of // the pretax in cents, the remainder split off so nothing leaves the range // doubles hold exactly. It is lib/tax-rate.mjs taxCentsOn written out, because // this page cannot import it -- and tests/tax-rate-parity.test.mjs runs this // function against that one, and against public/accept.html, for every cent // up to $20,000 and at every exact half cent. Self-contained on purpose: // several tests lift _ckTaxOn and _ckTaxLabel out of this file by name. function _ckTaxOn(pretax) { var c = Math.round(Number(pretax) * 100), r = c % 100000; if (r < 0) r += 100000; return _ckTaxExempt ? 0 : ((c - r) / 100000 * 8875 + Math.floor((r * 8875 + 50000) / 100000)) / 100; } function _ckTaxLabel() { return _ckTaxExempt ? 'Tax (exempt)' : 'Tax (8.875%)'; } function _ckFetchCreditBalance() { _ckCreditBalance = 0; _ckShipCredit = null; // A BELOW-ZERO BALANCE IS IMPOSSIBLE BY DESIGN, so if one arrives it is a // corrupted ledger and must not be rendered as an empty space where a credit // row belongs. Silence made three different things look identical: no credit, // a failed request, and a ledger that had gone wrong. Only the first is // ordinary. // // It is still not APPLIED -- spending against a negative balance would be // wrong -- but it is reported, so somebody can see it. fetch('/api/credit-balance').then(function (r) { if (!r.ok) { throw new Error('credit-balance ' + r.status); } return r.json(); }).then(function (j) { if (!j) return; var changed = false; if (j.balance > 0) { _ckCreditBalance = Math.round(j.balance * 100); changed = true; } else if (Number(j.balance) < 0) { try { qEvent('credit_balance_negative', { metadata: { balance: j.balance } }); console.error('store credit balance is NEGATIVE (' + j.balance + ') — a spend is counting against a grant that is gone. Credit is hidden at checkout until this is repaired.'); } catch (e) {} } if (j.shipCredit) { _ckShipCredit = j.shipCredit; changed = true; } if (!!j.taxExempt !== _ckTaxExempt) { _ckTaxExempt = !!j.taxExempt; changed = true; } if (changed) renderCheckoutSummary(); }).catch(function (e) { // The request FAILED -- signed out, server error, network. That is not the // same as having no credit, and it used to look the same on screen. try { qEvent('credit_balance_unavailable', { metadata: { detail: String((e && e.message) || e).slice(0, 200) } }); } catch (e2) {} }); } // The credit rows under the totals. Total stays the total - what changes is // how much of it the card is asked for. function _ckCreditRowsHtml(grandDollars, opts) { if (!(_ckCreditBalance > 0)) return ''; const on = _ckUseCredit; // The choice itself: a checkbox, because 'don't use' as a fine-print link // did not read as an option. Same row either way, so the balance is always // visible and nothing looks vanished when it is off. // data-credit-toggle + a delegated listener, NOT an inline onchange: this // script is a module, so its functions are not globals and inline handlers // cannot reach them - the checkbox called a name that did not exist and // threw silently. A document-level listener has no such scope problem. const box = (opts && opts.toggle) ? '' : ''; if (!on) return box; const split = _ckCreditSplit(Math.round(grandDollars * 100)); if (split.appliedCents <= 0) return box; // '-$30' with no context reads as 'my credit is gone'. Say what stays. const left = _ckCreditBalance - split.appliedCents; const leftRow = left > 0 ? '
    $' + formatPrice(left / 100) + ' in credit stays on your account
    ' : ''; // Reads top-down as the customer thinks: what was taken, what remains // theirs, then what the card is asked for. return box + '
    Store credit' + '−$' + formatPrice(split.appliedCents / 100) + '
    ' + leftRow + '
    Amount due$' + formatPrice(split.remainderCents / 100) + '
    '; } document.addEventListener('change', function (e) { if (e.target && e.target.matches && e.target.matches('[data-credit-toggle]')) _ckToggleCredit(); }); function _ckToggleCredit() { _ckUseCredit = !_ckUseCredit; renderCheckoutSummary(); // On the payment step, the toggle changes what the card is asked for, so the // step rebuilds - and rebuilds back onto the sub-view the customer was on. // A mounted card element is torn down with it; the next 'Pay by card' click // creates a fresh PaymentIntent at the new amount, which is exactly what an // amount change requires anyway. const payMounted = document.getElementById('ckTotalsPay'); if (payMounted && typeof renderPaymentStep === 'function') { const sc = document.getElementById('ckSubCheckout'); const wasOnPay = !!(sc && sc.style.display !== 'none'); renderPaymentStep(); if (wasOnPay && typeof goToCheckoutSub === 'function') goToCheckoutSub(); } } let ckDiscount = null; // { code, kind:'percent'|'fixed', value } // Held outside the field, because applying a code re-renders the whole summary — // which rebuilds #ckDiscWrap and threw away the message we had just written into // it. A rejected code said nothing at all: the reason flashed and vanished. let ckDiscountMsg = null; // { text, kind: 'err' | 'ok' } let ckDiscountAuto = false; // offered by us rather than typed let ckCcEmails = []; let ckBillingSameAsShip = true; let _ckBillingAddr = null; // WHAT THE CODE APPLIES TO, on screen. // // This must agree with lib/vouchers.mjs voucherCoverage to the cent, because // the card is charged the SERVER's number (api/payment-intent takes // priced.cents). On 2026-08-22 it did not: a code scoped to FDM PLA at // standard discounted a Next Biz Day order in full -- $99.46 of parts plus // $124.32 of expedite -- and the screen read $0.00 while the server would have // charged the $124.32. tests/discount-parity.test.mjs now runs this function // and the server's against the same carts. // // Christina, 2026-08-22: "if they select a lead time that is not covered, then // it should subtract the cost and they pay the rush fee." function _ckOfferFor(p) { const scope = ckDiscount && ckDiscount.appliesTo; if (!Array.isArray(scope) || !scope.length) return null; if (!p || !p.tech || !p.material) return null; return scope.find(function (o) { return o && String(o.tech) === String(p.tech) && String(o.material) === String(p.material); }) || null; } function _ckCoveredBase(base) { const b = Number(base) || 0; const scope = ckDiscount && ckDiscount.appliesTo; if (!Array.isArray(scope) || !scope.length) return b; // unrestricted: the whole cart const applied = _ckAppliedParts(); const tier = orderDeliveryTier; let total = 0; for (const p of applied) { const offer = _ckOfferFor(p); if (!offer) continue; // a material it does not name const tiers = Array.isArray(offer.tiers) ? offer.tiers.map(String) : []; if (!tiers.length) continue; const atChosen = computeOrderMinimums([p], tier).grandTotal; if (tiers.indexOf(String(tier)) >= 0) { total += atChosen; continue; } // A faster lead time than the code names: it covers the cost at the lead // time it DOES name, and the customer pays the difference. Capped at what // they are actually paying, so a dearer covered tier never over-credits. let best = 0; for (const t of tiers) { const at = computeOrderMinimums([p], t).grandTotal; if (isFinite(at) && at > best) best = at; } total += Math.min(best, atChosen); } return Math.min(total, b); } function _ckDiscountAmount(base) { if (!ckDiscount) return 0; const b = Number(base) || 0; const covered = _ckCoveredBase(b); if (covered <= 0) return 0; let amt = ckDiscount.kind === 'percent' ? covered * (Number(ckDiscount.value) / 100) : Number(ckDiscount.value); if (!isFinite(amt) || amt < 0) amt = 0; return Math.min(amt, covered); } function renderDiscountField() { const wrap = document.getElementById('ckDiscWrap'); if (!wrap) return; if (ckDiscount) { wrap.innerHTML = '
    ' + ckDiscount.code + '' + '' + (ckDiscountAuto ? 'applied for your first order' : 'applied') + '' + '
    '; const rm = document.getElementById('ckDiscRemove'); if (rm) rm.addEventListener('click', removeDiscount); } else { wrap.innerHTML = '
    ' + '
    ' + '
    ' + (ckDiscountMsg ? _escHtml(ckDiscountMsg.text) : '') + '
    '; const a = document.getElementById('ckDiscApply'); if (a) a.addEventListener('click', applyDiscountCode); const i = document.getElementById('ckDiscInput'); if (i) i.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); applyDiscountCode(); } }); } } function removeDiscount() { // Also forget it for next time. A code that came back after being removed // would look like the button did not work. _ckTypedCode = null; ckDiscount = null; ckDiscountMsg = null; ckDiscountAuto = false; renderDiscountField(); renderCheckoutSummary(); if (typeof updateContinueState === 'function') updateContinueState(); } // The code the customer typed, kept across a trip back to the configurator. // Only the code — never the approval, which is re-earned against the new cart. let _ckTypedCode = null; // One request shape, used by the typed check and the re-check on re-entry, so // the two cannot drift into judging the same code by different rules. async function _checkDiscountCode(code) { const mins = computeOrderMinimums(_ckAppliedParts(), orderDeliveryTier); const r = await fetch('/api/discount', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // The parts are sent so a code restricted to particular materials can say // so WHILE the customer is typing it, rather than silently taking nothing // off at checkout. Advisory only, exactly like atMinimum: server-price // re-derives coverage from the parts IT priced before any money moves. body: JSON.stringify({ code, subtotal: mins.grandTotal, atMinimum: Number(mins.adjustmentTotal || 0) > 0, tier: orderDeliveryTier, parts: _ckAppliedParts().map(function (p) { return { tech: p && p.tech, material: p && p.material }; }), }), }); return await r.json(); } // A code the customer typed beats one we offered, so the re-check has to settle // BEFORE the auto offer goes out — otherwise which one wins comes down to which // request happens to come back first. // // offerAutoVoucher had only one caller, in 'fill the minimum'. So a first-order // voucher only ever reached customers who happened to use that button, which is // the opposite of the point: a voucher nobody is told about only reaches the // people we remembered to tell. Entering checkout is when to offer it. async function _ckRestoreDiscount() { await _reapplyTypedCode(); if (!ckDiscount) await offerAutoVoucher(); } // Re-apply a remembered code against the cart as it is NOW. async function _reapplyTypedCode() { if (!_ckTypedCode || ckDiscount) return; const code = _ckTypedCode; try { const j = await _checkDiscountCode(code); // They may have typed something else while this was in flight. if (ckDiscount || _ckTypedCode !== code) return; if (j && j.valid) { ckDiscount = { code: j.code, kind: j.kind, value: j.value, appliesTo: j.appliesTo || null, shippingAppliesTo: j.shippingAppliesTo || null }; ckDiscountMsg = null; ckDiscountAuto = false; } else { // Editing the cart can push an order under a minimum a code needed. Say // that, rather than letting the discount vanish and leaving them to // notice the total went up. _ckTypedCode = null; ckDiscountMsg = { text: code + ' no longer applies: ' + ((j && j.reason) || 'that code is not valid.'), kind: 'err' }; } renderDiscountField(); renderCheckoutSummary(); if (typeof updateContinueState === 'function') updateContinueState(); } catch (e) { /* leave it remembered; the next entry tries again */ } } async function applyDiscountCode() { const input = document.getElementById('ckDiscInput'); const msg = document.getElementById('ckDiscMsg'); const btn = document.getElementById('ckDiscApply'); if (!input) return; const code = input.value.trim(); if (!code) { ckDiscountMsg = null; renderDiscountField(); return; } ckDiscountMsg = { text: 'Checking…', kind: '' }; if (msg) { msg.className = 'ck-disc-msg'; msg.textContent = 'Checking…'; } if (btn) btn.disabled = true; try { const j = await _checkDiscountCode(code); if (j && j.valid) { ckDiscount = { code: j.code, kind: j.kind, value: j.value, appliesTo: j.appliesTo || null, shippingAppliesTo: j.shippingAppliesTo || null }; ckDiscountMsg = null; ckDiscountAuto = false; _ckTypedCode = j.code; } else { ckDiscount = null; _ckTypedCode = null; // Say WHY. The server sends a sentence per rule — expired, under the // minimum, already used on this account — and a bare "invalid" for a code // that does not exist. ckDiscountMsg = { text: (j && j.reason) || 'That code is not valid.', kind: 'err' }; } renderDiscountField(); renderCheckoutSummary(); if (typeof updateContinueState === 'function') updateContinueState(); } catch (e) { ckDiscountMsg = { text: 'Could not check that code — try again.', kind: 'err' }; renderDiscountField(); } finally { if (btn) btn.disabled = false; } } // A first-order voucher nobody is told about only reaches the customers we // remembered to tell. If we are willing to give one, offer it. async function offerAutoVoucher() { if (ckDiscount) return; try { const mins = computeOrderMinimums(_ckAppliedParts(), orderDeliveryTier); if (!(mins.grandTotal > 0)) return; const r = await fetch('/api/auto-voucher', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subtotal: mins.grandTotal, atMinimum: Number(mins.adjustmentTotal || 0) > 0 }), }); const j = await r.json(); if (!j || !j.voucher || ckDiscount) return; ckDiscount = { code: j.voucher.code, kind: j.voucher.kind, value: j.voucher.value }; ckDiscountAuto = true; ckDiscountMsg = null; renderDiscountField(); renderCheckoutSummary(); } catch (e) { /* no offer is a fine outcome */ } } function _ckGrandTotalNum() { const applied = _ckAppliedParts(); const mins = computeOrderMinimums(applied, orderDeliveryTier); return _ckPayTotals(mins.grandTotal, _ckShipCost(), _ckDiscountAmount(mins.grandTotal), null).grand; } // Remembered so the crumbs can be repainted when the CART changes rather than // only when the step does — picking an RFQ lead time on step 2 renames steps 3 // and 4, and nothing was asking them to redraw. let _ckCrumbStep = 1; function refreshCrumbs() { if (typeof setCrumbs === 'function') setCrumbs(_ckCrumbStep); } function setCrumbs(step) { _ckCrumbStep = step; const el = document.getElementById('ckCrumbs'); if (!el) return; const _rfq = (typeof cartNeedsRfq === 'function') && cartNeedsRfq(); // An RFQ takes no payment and ships nothing yet, so "Billing & shipping" and // "Checkout" describe steps that are not happening. Lead time and delivery // still do — that is what the quote has to answer. const items = _rfq ? [['1', 'Configure'], ['2', 'Lead Time & Delivery'], ['3', 'Your details'], ['4', 'Send request'], ['5', 'Confirmation']] : [['1', 'Configure'], ['2', 'Lead Time & Delivery'], ['3', 'Billing & shipping'], ['4', 'Checkout'], ['5', 'Confirmation']]; el.innerHTML = items.map((it, i) => { const num = i + 1; const cls = num < step ? 'done clickable' : (num === step ? 'cur' : ''); const sep = i < items.length - 1 ? '›' : ''; return `${it[0]}${it[1]}${sep}`; }).join(''); } function goToPaymentStep() { try { qEvent('checkout_payment'); } catch (e) {} const s2 = document.getElementById('ckStep2'), s3 = document.getElementById('ckStep3'); if (!s2 || !s3) return; s2.style.display = 'none'; s3.style.display = ''; _ckStep = 3; _ckStep3Mode = 'pay'; setCrumbs(3); const back = document.getElementById('checkoutBack'); if (back) back.innerHTML = '← Back to Lead Time & Delivery'; _ensurePayMode().then(function () { renderPaymentStep(); try { var _cksc = document.getElementById('checkoutScreen'); if (_cksc) _cksc.scrollTop = 0; window.scrollTo(0, 0); } catch (e) {} }); } function backToLeadStep() { const s2 = document.getElementById('ckStep2'), s3 = document.getElementById('ckStep3'); if (!s2 || !s3) return; s3.style.display = 'none'; s2.style.display = ''; _ckStep = 2; setCrumbs(2); const back = document.getElementById('checkoutBack'); if (back) back.innerHTML = '← Back to Configurator'; try { var _cksc = document.getElementById('checkoutScreen'); if (_cksc) _cksc.scrollTop = 0; window.scrollTo(0, 0); } catch (e) {} } function goToCheckoutSub() { var sb = document.getElementById('ckSubBilling'), sc = document.getElementById('ckSubCheckout'); if (sb) sb.style.display = 'none'; if (sc) sc.style.display = ''; _ckStep = 4; setCrumbs(4); var back = document.getElementById('checkoutBack'); if (back) back.innerHTML = '\u2190 Back to Billing & shipping'; if (typeof _updatePayGate === 'function') _updatePayGate(); try { var _cksc = document.getElementById('checkoutScreen'); if (_cksc) _cksc.scrollTop = 0; window.scrollTo(0, 0); } catch (e) {} } function backToBillingSub() { var sb = document.getElementById('ckSubBilling'), sc = document.getElementById('ckSubCheckout'); if (sc) sc.style.display = 'none'; if (sb) sb.style.display = ''; _ckStep = 3; setCrumbs(3); var back = document.getElementById('checkoutBack'); if (back) back.innerHTML = '\u2190 Back to Lead Time & Delivery'; try { var _cksc = document.getElementById('checkoutScreen'); if (_cksc) _cksc.scrollTop = 0; window.scrollTo(0, 0); } catch (e) {} } // A TAG IN THE DOM IS NOT A SCRIPT THAT LOADED. // // This read `if (document.getElementById(id)) return resolve()` -- present // means done -- and nothing ever removed a tag whose load had FAILED. So one // failed fetch poisoned every retry for the life of the page: the second call // found the dead tag, resolved instantly without re-downloading, and the caller // carried on to use a global that was never defined. // // Daniela Beraun, 2026-09-08: nineteen presses of "Pay by card" in fourteen // minutes, four of them inside nine seconds -- which is only possible because // after the first failure each retry cost no network round trip at all. Every // one threw ReferenceError on `Stripe` and showed her a sentence, so the card // iframe never mounted and there was nothing to type a card into. // // Three states, told apart: loaded (resolve), in flight (join it, rather than // downloading a second copy), and gone (a failed tag is REMOVED, so the next // call genuinely re-attempts). function _loadScript(src, id) { return new Promise((resolve, reject) => { const existing = id ? document.getElementById(id) : null; if (existing) { if (existing.dataset && existing.dataset.mlqLoaded === '1') return resolve(); existing.addEventListener('load', () => resolve()); existing.addEventListener('error', () => reject(new Error('load_failed'))); return; } const sc = document.createElement('script'); sc.src = src; if (id) sc.id = id; sc.onload = () => { try { sc.dataset.mlqLoaded = '1'; } catch (e) {} resolve(); }; sc.onerror = () => { try { sc.remove(); } catch (e) {} reject(new Error('load_failed')); }; document.head.appendChild(sc); }); } function renderCcChips() { const el = document.getElementById('payCcChips'); if (!el) return; el.innerHTML = ckCcEmails.map((em, i) => '' + _escHtml(em) + '').join(''); el.querySelectorAll('button').forEach(b => b.addEventListener('click', () => { ckCcEmails.splice(parseInt(b.dataset.i, 10), 1); renderCcChips(); })); } function addCcEmail() { const input = document.getElementById('payCcInput'); if (!input) return; const parts = input.value.split(',').map(x => x.trim()).filter(Boolean); let ok = parts.length > 0; // Lowercased and capped at ten to match CC_MAX in lib/quote-share.mjs. CC is // a grant to open and PAY the quote, and the server stores at most ten of them // lowercased -- so accepting an eleventh here, or a differing case, would show // the customer a chip for somebody who silently got nothing. parts.forEach(v => { if (/^[^@\s<>"'()\/\\]+@[^@\s<>"'()\/\\]+\.[^@\s<>"'()\/\\]+$/.test(v)) { const _v = v.toLowerCase(); if (ckCcEmails.indexOf(_v) !== -1) return; if (ckCcEmails.length >= 10) { ok = false; return; } ckCcEmails.push(_v); } else ok = false; }); if (!ok) { input.style.borderColor = '#B63113'; } else { input.value = ''; input.style.borderColor = ''; } renderCcChips(); } function _updateBillingVerified() { const el = document.getElementById('ckBillingVerified'); if (!el) return; el.innerHTML = (_ckBillingAddr && _ckBillingAddr.zip) ? '✓ Address verified' : ''; } // wireBillingAutocomplete was deleted on 2026-08-03. It autocompleted an input // called #ckBillingInput, which no longer exists ANYWHERE -- not in the markup, // not built by any template; only a CSS rule and two getElementById calls that // always return null survive it. It is a leftover from an older checkout. // // It was reported as "billing autocomplete is defined but never called, close to // a one-line fix". Calling it would have wired a listener to null and changed // nothing, while looking exactly like a fix. The billing address form that DOES // render (the 'Provide billing info' card) is wired at _renderBillCard via // wireAddressAutocomplete on #ckbillLine1 -- the same function shipping uses -- // and writes _ckBill, which is what checkout submits. // A phone number has to survive formatting — (555) 555-5555, 555.555.5555 and // +1 555 555 5555 are all the same ten digits. function _phoneDigits(v) { return String(v || '').replace(/\D+/g, ''); } function _phoneOk(v) { // Mirrors makelab-core/contacts normalizePhone, which is what the server gates // on. Counting digits was not enough: '1234567890' and '0000000000' both have // ten, and both are refused at checkout now, so the browser has to refuse them // here or the customer only finds out when payment fails. // tests/phone-is-required.test.mjs asserts these two never drift apart. const s = String(v == null ? '' : v).trim(); if (!s) return false; if (['unknown','n/a','na','none','null','tbd','no phone','-'].indexOf(s.toLowerCase()) >= 0) return false; const d = _phoneDigits(s.replace(/\s*(?:\bx|\bext\.?|\bextension|#)\s*\d+\s*$/i, '')); if (!d) return false; if (/^(\d)\1+$/.test(d)) return false; if ('01234567890123456789'.indexOf(d) >= 0 || '98765432109876543210'.indexOf(d) >= 0) return false; const ten = (d.length === 11 && d[0] === '1') ? d.slice(1) : d; if (ten.length === 10) return /^[2-9]/.test(ten[0]) && /^[2-9]/.test(ten[3]); return s.charAt(0) === '+' && d.length >= 8 && d.length <= 15; } // A shipping label needs a person, not a placeholder. Any non-empty string used // to pass, so "asdf" and "." went through and came back as a delivery problem nobody // could chase. Two parts, each at least two letters — which is the weakest rule // that still rejects a single word or a keyboard mash, and does not exclude // names with hyphens, apostrophes or accents. // ANY WAY THEY TYPE IT. // // Christina, 2026-09-02: "can you enforce or auto format a phone number no // matter how its typed in?" -- after a checkout where the number was there and // the field still read as wrong. // // Reformats rather than refuses: 9739074078, 973.907.4078, +1 (973) 907-4078 // and 973 907 4078 x12 are one number typed four ways, and only the first of // them looks like what the label asks for. Nothing the validator would have // accepted is discarded -- an extension is kept, because a driver may need it. // // A number this cannot read is returned untouched, so a genuinely wrong entry // is still refused by _phoneOk rather than silently mangled into something // that passes. function _formatPhone(v) { var raw = String(v == null ? '' : v).trim(); if (!raw) return raw; var body = raw, ext = ''; var m = raw.match(/\s*(?:\bx|\bext\.?|\bextension|#)\s*(\d+)\s*$/i); if (m) { ext = ' x' + m[1]; body = raw.slice(0, m.index); } var d = _phoneDigits(body); if (!d) return raw; var ten = (d.length === 11 && d[0] === '1') ? d.slice(1) : d; if (ten.length === 10) return '(' + ten.slice(0, 3) + ') ' + ten.slice(3, 6) + '-' + ten.slice(6) + ext; if (raw.charAt(0) === '+' && d.length >= 8 && d.length <= 15) return '+' + d + ext; return raw; } function _fullNameOk(v) { const t = String(v || '').trim(); if (t.length < 3) return false; if (/[0-9@]/.test(t)) return false; const parts = t.split(/\s+/).filter(function (w) { return /[\p{L}]{2,}/u.test(w); }); return parts.length >= 2; } // Why a field is not accepted, or null when it is. Shown under the field — a // disabled Continue with no explanation is not validation, it is a dead end. function _fieldProblem(id, value) { if (id === 'payName') return _fullNameOk(value) ? null : 'Enter your first and last name.'; if (id === 'payPhone') { const d = _phoneDigits(value); if (!d.length) return 'Enter a phone number we can reach you on.'; if (d.length < 10) return 'That is too short for a phone number.'; if (d.length > 15) return 'That is too long for a phone number.'; // THE EXPLAINER MUST KNOW EVERY RULE THE GATE ENFORCES. // // Christina, 2026-09-02, on a checkout with every field filled and a dead // button: "(123) 456-7891". Ten digits, so the count checks above passed and // this returned null -- no complaint, nothing marked, no message -- while // _phoneOk refused it, because a NANP area code cannot start with 1. The // gate said no and the explainer said everything is fine. // // Anything _payReady refuses has to be sayable here, so the last word is // _phoneOk itself rather than a second, looser copy of its rules. if (!_phoneOk(value)) return 'That does not look like a real number — check the area code.'; return null; } // The PO is only ever a problem when the account requires one and it is // empty. typeof-guarded: harnesses lift this function on its own. if (id === 'payPo') { var _poReq = (typeof _ckPoRequired === 'function') && _ckPoRequired(); var _poHas = (typeof _ckCleanPo === 'function') ? !!_ckCleanPo(value) : !!String(value == null ? '' : value).trim(); return (_poReq && !_poHas) ? 'Enter your PO number to continue.' : null; } if (id === 'payEmail') return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || '').trim()) ? null : 'Enter a valid email address.'; return null; } function _showFieldProblem(id) { const el = document.getElementById(id); if (!el || !el.parentNode) return; // Readonly fields come from the account — there is nothing to correct here. const problem = el.readOnly ? null : _fieldProblem(id, el.value); var note = el.parentNode.querySelector('.ck-field-err'); if (!problem) { if (note) note.remove(); el.classList.remove('ck-field-bad'); return; } if (!note) { note = document.createElement('span'); note.className = 'ck-field-err'; el.parentNode.appendChild(note); } note.textContent = problem; el.classList.add('ck-field-bad'); } // A PART WHOSE FILE NEVER ARRIVED MUST NOT BE BUYABLE. // // Christina, 2026-09-09: "they shouldnt be able to check out if its not fully // uploaded... checkout should refuse a part whose upload failed, and it should // stop them and tell them." // // chan@fresh.com, 2026-09-09: seven uploads, every PUT to R2 refused with // "Failed to fetch" three times over (a corporate proxy, IP 165.225.39.1). // _persistUpload returned null each time, so every cart part kept uploadId // null, and order 11708 was paid for by card at 16:15 -- $229.07 for six parts // we hold no bytes for. _uploadLost DID warn them; it is a dismissible banner // beside a working Pay button, and the money moved anyway. // // This closes the door instead of describing it. _updatePayGate is the one // place that decides whether #ckPayGated is on screen, and that container // holds card, PayPal, terms, bank and credit -- so one condition here shuts // every payment method at once. It deliberately does NOT touch #ckDownloadPdf: // the quote is the customer's document and stays downloadable. function _ckUploadsPending() { // Still in flight is NOT the same as failed -- it means "not yet", and the // gate re-opens by itself when the last one lands (_trackPersist finally). // // _splitFilePending is deliberately NOT consulted: it is pushed to and never // drained, so reading it would shut checkout permanently for anyone who ever // split a body. _splitFileWaiting is the one that empties. try { if (Object.keys(window.__persistPending || {}).length) return true; } catch (e) {} try { if (typeof _splitFileWaiting !== "undefined" && _splitFileWaiting.length) return true; } catch (e) {} return false; } function _ckBlindParts() { // A LOCKED QUOTE IS EXEMPT, and that is not a loophole. // // _persistUpload returns at once for a locked quote, so its parts never // upload from this browser and carry no id by design. The server recovers // those ids from the quote itself (api/place-order.mjs:547-563), under a // comment that says recovering a file link must never refuse a payment. // Order 11569 ($3,703.14) is why that recovery exists; refusing here would // undo it. try { if (window.__lockedQuote) return []; } catch (e) {} try { return _ckAppliedParts().filter(function (p) { return p && !p.uploadId; }); } catch (e) { return []; } } // Named files, because "an upload failed" with six parts on screen tells the // customer nothing about which one to add again. function _ckFileGateReason(blind, pending) { if (pending) return "One moment \u2014 we are still receiving your files."; if (!blind || !blind.length) return null; var names = blind.map(function (p) { return String(p.name || "your file"); }); var shown = names.slice(0, 4).join(", ") + (names.length > 4 ? " and " + (names.length - 4) + " more" : ""); // A file its network refused by both routes cannot be added again from // here: "remove it and add it again" was the one instruction Huhtamaki was // given and could never follow. Emailing works for every file listed, so // it is the instruction whenever any of them was refused. var _refused = false; try { _refused = blind.some(function (p) { return _partUploadBlocked(p); }); } catch (e) {} if (_refused) return "We never received " + (names.length > 1 ? "these files" : "this file") + ": " + shown + ". " + blockedAdvice(names.length > 1); return "We never received " + (names.length > 1 ? "these files" : "this file") + ": " + shown + ". We cannot print " + (names.length > 1 ? "them" : "it") + ", so this quote cannot go ahead until " + (names.length > 1 ? "they are" : "it is") + " uploaded. Please remove " + (names.length > 1 ? "those parts" : "that part") + " and add " + (names.length > 1 ? "them" : "it") + " again."; } // THE ONE QUESTION EVERY DOOR ASKS. // // Christina, 2026-09-23: "prevent them from doing anything on the quoter unless // their models have loaded". // // The pay gate has asked this since chan@fresh.com on 2026-09-09 (order 11708, // $229.07 for six parts we hold no bytes for). The other two doors never did, // and on 2026-09-23 they let five quotes out the same way -- Q-15976-1, // Q-15977-1, Q-15982-1 and Q-15984-1 reached brad.dahlman@huhtamaki.com and // cxie@fresh.com priced at $3,297.38 with every file 404 in R2, and Q-15985-1 // followed through the RFQ door. Same company as chan@, same corporate proxy // refusing the browser's PUT. // // One function so the three doors cannot drift: a reason string when we are not // holding the files, null when we are. Callers refuse on truthiness and show // the string -- they never re-derive the question. function _ckFileGateWhy() { var pending = _ckUploadsPending(); var blind = _ckBlindParts(); // AND WE KNOW HOW MANY PIECES WE ARE CHARGING FOR (2026-10-01): an SLA part // still being counted holds the same three doors, and is a "not yet" in the // same way an upload in flight is. typeof-guarded: harnesses lift this // function on its own. if (!pending && !(blind && blind.length)) return (typeof _ckPiecesGateWhy === 'function') ? _ckPiecesGateWhy() : null; return _ckFileGateReason(blind, pending); } function _payReady() { const emv = (document.getElementById('payEmail') || {}).value || ''; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emv.trim())) return false; const _nameEl = document.getElementById('payName'); // A name that came from the account is trusted; one being typed has to be a name. if (_nameEl && !_nameEl.readOnly && !_fullNameOk(_nameEl.value)) return false; if (_nameEl && _nameEl.readOnly && !String(_nameEl.value || '').trim()) return false; if (!_phoneOk((document.getElementById('payPhone') || {}).value)) return false; // A PO number, when the account requires one. The page is told by pay-mode; // place-order, payment-intent and paypal-order are what actually refuse. if (typeof _ckPoMissing === 'function' && _ckPoMissing()) return false; const isPickup = orderDispatch === 'pickup'; if (!isPickup) { if (!(_ckShip && _ckShip.saved)) return false; // SHIPPING WITH NO CARRIER CHOICE IS NOT READY TO PAY. // // Christina, 2026-09-02: fran@synchro.com could not pay, and neither card // nor PayPal said why. The server refuses a shipping order that arrives // with no rate -- lib/server-price.mjs throws ship_rate_invalid on // (!rateId || !shipmentId) -- and it refuses BOTH doors identically, so // "pick a shipping option" reached her as "both payment methods are // broken". Verified in production: POST /api/payment-intent 400 // ship_rate_invalid, then POST /api/paypal-order 400 seven seconds later. // // ONLY A SHIPPING ORDER NEEDS A CARRIER RATE. // // This said `if (!ckSelectedRate)` for anything that was not pickup, and // that broke COURIER for everybody -- Christina, 2026-09-02: "people can't // check out", with Joseph Zvejnieks at Ferguson & Shamamian sitting on a // filled-in form, Courier $15.00, and a dead Continue button. // // Courier is our own flat rate, and choosing it explicitly clears // ckSelectedRate a few hundred lines up (`if (orderDispatch !== 'shipping') // ckSelectedRate = null`). There was never a rate to find. The three // dispatches are pickup, courier and shipping; only the last one buys a // carrier service, and only it can be refused for the want of one. // // Still blocks nothing the server would accept: the two rates carrying no // EasyPost id -- 'provide your own label' and freight -- both SET // ckSelectedRate, so neither is caught here. // NOT A GATE. Twice now this hid the whole payment area -- once for every // courier customer, and then again for anyone on shipping who had not yet // picked a carrier. _payReady controls whether ckPayGated renders AT ALL, // so a false here takes Stripe, PayPal, terms and bank transfer off the // screen with no card to click. Christina, 2026-09-02: "no one can check // out", "i tried too and the stripe does not appear." // // The reason fran could not pay was never that the buttons were shown; it // was that the refusal said nothing. That is fixed where it belongs, in the // message. Missing a rate is now SAID, not hidden -- see _payGateReason and // the ship_rate_invalid branch in initCardPayment, which names it and sends // her back one step. // // A condition that decides whether a customer can see how to pay is not the // place to be clever about what the server might refuse. } const same = isPickup ? false : ckBillingSameAsShip; if (isPickup || !same) { if (!(_ckBill && _ckBill.saved)) return false; } return true; } // SAY THE THING THAT IS ACTUALLY MISSING. // // The gate's one line was "Complete your billing & shipping details to pay." // When the only gap is the carrier choice that sentence is not just unhelpful, // it is wrong -- the details ARE complete, so it sends the customer back to // re-check fields that were never the problem. That is the same failure as the // refusal it replaces: naming the wrong cause. function _payGateReason() { // The carrier rate is NOT named here. This message only ever shows when the // payment area is hidden, and a missing rate no longer hides it -- blaming // the rate for whatever else is wrong would be the same misdirection as // blaming the billing details. // NAME THE FIELD, and mark it. Christina, 2026-09-02: "if they cannot check // out because information is not filled, then can you highlight what is // missing? a client told me they could not check out." // // _fieldProblem already knows why each field is not accepted -- it is what // the blur handler shows -- but the gate ignored it and said "complete your // billing & shipping details" whatever was wrong, including when the details // were complete and the phone simply had nine digits. So the customer is // sent to re-check the one thing that is already right. var _first = null; ['payEmail', 'payName', 'payPhone', 'payPo'].forEach(function (id) { var el = document.getElementById(id); if (!el || el.readOnly) return; // ALWAYS re-check, including the fields that are FINE. // // Christina, 2026-09-02: "the name and phone number field are filled out // automatically but still point out red, so i have to retype it or // something. why? if its there then its there." // // Because this used to skip valid fields. The gate runs once while the // form is still empty and marks all three red; the account then fills them // in; and nothing ever looked at them again, so the red stayed until she // typed. _showFieldProblem CLEARS a field that is now good -- it just has // to be asked. try { _showFieldProblem(id); } catch (e) {} var why = _fieldProblem(id, el.value); if (why && !_first) _first = why; }); if (_first) return _first; if (orderDispatch !== 'pickup' && !(_ckShip && _ckShip.saved)) return 'Add a shipping address to pay.'; if (!(_ckBill && _ckBill.saved) && !(orderDispatch !== 'pickup' && ckBillingSameAsShip)) { return 'Add a billing address to pay.'; } return 'Complete your billing & shipping details to pay.'; } // Why this account cannot pay at all: pay-mode said it is blocked from quoting // and ordering (2026-10-02). The pay gate names it before anything else. // // Staff acting in the customer's session are told why, and held until they // choose "Order anyway" (spec: "red warning, staff can override with a // deliberate send anyway"; final review 2026-10-04 -- they used to be waved // through with nothing on screen). place-order records it under their name. function _ckBlockedWhy() { var b = window._customerBlocked; if (!b) return null; if (b.staffActing) return window._staffOrderAnyway ? null : 'This customer is blocked from quoting and ordering' + (b.reason ? ': ' + b.reason : '') + '. Choose Order anyway to place it for them; it is recorded under your name.'; return b.detail || "We can't take new quotes or orders on this account. Please contact us at hello@makelab.com or +1-888-355-1570."; } // Staff acting for a blocked customer choose to place the order anyway. function _ckOrderAnyway() { window._staffOrderAnyway = true; try { qEvent('checkout_block_overridden', { metadata: { by: 'staff in session' } }); } catch (e) {} _updatePayGate(); } // Reached from the button's inline onclick, so it has to be on window: this // script is a module, and its functions are not globals. try { window._ckOrderAnyway = _ckOrderAnyway; } catch (e) {} function _updatePayGate() { const ready = _payReady(); // The details being complete is one question; whether we actually hold the // files is another, and only the second one is new. They are kept apart so // the PDF button below can keep answering the first. const _fileWhy = _ckFileGateWhy(); // A blocked account pays nothing here, whatever else is ready. const _blockWhy = (typeof _ckBlockedWhy === 'function') ? _ckBlockedWhy() : null; const canPay = ready && !_fileWhy && !_blockWhy; const _cont = document.getElementById('ckBillingContinue'); if (_cont) _cont.disabled = !canPay; const gated = document.getElementById('ckPayGated'); const msg = document.getElementById('ckPayGateMsg'); if (gated) gated.style.display = canPay ? '' : 'none'; // Order anyway: only for staff acting in a blocked customer's session, until chosen. const _anyway = document.getElementById('ckOrderAnyway'); if (_anyway) { var _cb = window._customerBlocked; _anyway.style.display = (_cb && _cb.staffActing && !window._staffOrderAnyway) ? '' : 'none'; } if (msg) { msg.style.display = canPay ? 'none' : ''; if (!canPay) { // The file problem is named FIRST: it is the one the customer can fix // and the one that is not about a form field. var _why = _blockWhy || _fileWhy || _payGateReason(); msg.textContent = _why; // RECORD WHY, NOT JUST SHOW WHY. // // Christina, 2026-09-02, three wrong guesses into one customer: // "i did the fraser on my end, he still could nto check out." The page // knows exactly which condition is closed and says so on screen, but // nobody is standing behind the customer reading it -- so when they tell // us afterwards that checkout did not work, all we have is the absence of // a payment attempt and a lot of theories. // // Only when the reason CHANGES: this runs on every keystroke. try { if (_why && _why !== window.__ckLastGateWhy) { window.__ckLastGateWhy = _why; qEvent('checkout_gate_closed', { metadata: { why: _why, dispatch: orderDispatch } }); } } catch (e) {} } else { try { window.__ckLastGateWhy = null; } catch (e) {} } } const pdf = document.getElementById('ckDownloadPdf'); // WAS DELIBERATELY `ready`, NOW `canPay`. Christina, 2026-09-23. // // The old rule was "a customer whose upload failed still owns their quote and // must be able to download it -- withholding the document would punish them // for our failed transfer." That reasoning held right up until the download // button turned out to be a WRITER: saveQuoteAndDownload POSTs /api/save-quote // before it renders the PDF, so every press minted a client-facing 'quoted' // row. Three of the four unopenable quotes sent on 2026-09-23 came through // this button, not through checkout. // // A quote we cannot print is not a document worth handing over, and it is // certainly not one worth recording. The customer is told which file to add // again instead. if (pdf) pdf.disabled = !canPay; var _scv = document.getElementById('ckSubCheckout'); var _checkoutVisible = _scv && _scv.style.display !== 'none'; // A FUNCTION, NOT A NUMBER, so PayPal reads the total when the customer // CLICKS rather than when the button was drawn. public/checkout-paypal.js // already accepts either (shownCents evaluates a function), so this costs // nothing and closes the same hole the card path had: a button rendered at // 20:12 was still offering that price at 22:13, and the server had moved on. if (canPay && !_ckPaypalInited && _checkoutVisible && !window._sim) { _ckPaypalInited = true; initPayPal(function () { return _ckGrandTotalNum(); }); } } // The 3% invoicing fee, stated where the customer decides to be invoiced. // The server charges it from the client record either way (lib/invoice-fee.mjs); // this exists so nobody is surprised by it on the invoice. Charged on the same // financed base the server uses: parts + delivery, after discount, before tax. // Which payment method the customer has picked. Null until they choose, so a // card order never shows an invoicing fee. var _ckPayMethod = null; function _ckSetPayMethod(m) { if (_ckPayMethod === m) return; _ckPayMethod = m; try { if (typeof renderCheckoutSummary === 'function') renderCheckoutSummary(); } catch (e) {} } // The fee on a given financed amount, for the method currently chosen. The // server recomputes this from the client record; this is what the customer // sees before they commit. function _ckInvoiceFeeAmount(base, method) { if ((arguments.length > 1 ? method : _ckPayMethod) !== 'terms') return 0; var opt = window._invoiceOpt; var pct = (opt && Number(opt.feePct)) || 0; if (!pct || !(base > 0)) return 0; return Math.round(base * pct * 100) / 100; } // FROM THE ORDER TOTAL TO WHAT IS PAID, IN THE SERVER'S ORDER. // // lib/server-price.mjs priceCart: the invoicing fee on what is financed, then // the pretax ROUNDED TO THE CENT, then tax on that, then the total. Step 2 did // exactly that. Step 3 (twice: its own total and the per-method pair) and // _ckGrandTotalNum each spelled it again and taxed the UNROUNDED pretax, which // counts a half cent twice, once in the tax and once in the total. An SLA part // at 88.1 cm3 x 3 on Extended, Net-30, is $652.685 before tax: the server rounds // that to $652.68, taxes it $57.94 and invoices $710.62. Step 3 taxed $652.685 // at $57.95 and showed $710.64 beside "Place order · invoice me". place-order // refuses anything past 1c, and reloading drew the same $710.64. On the live // model that was 9 of 13,608 single-part carts refused, every one Net-30, and // 627 more totals shown a cent off the charge (2026-09-29). // // Both checkout steps, the per-method totals _ckShownCentsFor sends, // _ckGrandTotalNum, a saved quote's breakdown and the quote PDF printed when // /api/quote-pdf-file cannot be reached read it. It is not the only spelling: // renderRfqStep's estimate and showOrderConfirmation's receipt still work the // total out themselves. Both round the pretax before taxing it, so they agreed // to the cent on 2026-09-29, but nothing holds them to this one. The method is // always passed, never read from _ckPayMethod in here: step 3 needs the terms // total and the plain one side by side, whichever is selected. function _ckPayTotals(orderTotal, ship, disc, method) { const financed = Math.max(0, orderTotal + ship - disc); const fee = _ckInvoiceFeeAmount(financed, method); const pretax = Math.round((financed + fee) * 100) / 100; const tax = _ckTaxOn(pretax); return { fee: fee, pretax: pretax, tax: tax, grand: Math.round((pretax + tax) * 100) / 100 }; } // The bank-transfer option. Offered only when the server says this account may // use it AND the bank details are configured — pay-mode strips bank_transfer // otherwise, so this never renders a button place-order would 503. // // Nothing is charged here. The order is created in '💵 Awaiting transfer', // held off the production floor and carrying no deadline, until someone // confirms the money arrived (Christina, 2026-08-11). The copy says that // plainly rather than implying the order is under way. function _ckBankTransferBox() { var ms = window._payMethods; if (!Array.isArray(ms) || ms.indexOf('bank_transfer') < 0) return ''; if (window._partnerBilling) return ''; return '
    ' + '
    Pay by bank transfer
    ' + '
    We' + "'" + 'll email you the account details and your order number to reference. Production starts once the transfer arrives — nothing is charged now.
    ' + '' + '
    '; } // fee and invoiced come from renderPaymentStep, which sends the same invoiced // figure as shownCents -- the server refuses a total the screen did not show, // so the invoiced total is printed here, beside the button that books it. function _ckInvoiceFeeNote(fee, invoiced) { var opt = window._invoiceOpt; var pct = (opt && Number(opt.feePct)) || 0; if (!pct) return ''; return '
    A ' + Math.round(pct * 100) + '% invoicing fee' + (fee > 0 ? ' ($' + formatPrice(fee) + ')' : '') + ' is added, plus tax.' + (invoiced > 0 ? ' You' + "'" + 'll be invoiced $' + formatPrice(invoiced) + '.' : '') + '
    '; } function wirePayExtras() { const ccAdd = document.getElementById('payCcAdd'); if (ccAdd) ccAdd.addEventListener('click', addCcEmail); const ccInput = document.getElementById('payCcInput'); if (ccInput) ccInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); addCcEmail(); } }); renderCcChips(); const same = document.getElementById('ckBillingSame'); const bWrap = document.getElementById('ckBillingWrap'); if (same) same.addEventListener('change', function () { ckBillingSameAsShip = same.checked; if (bWrap) bWrap.style.display = same.checked ? 'none' : 'block'; _updatePayGate(); }); const ed = document.getElementById('ckPayEditAddr'); if (ed) ed.addEventListener('click', backToLeadStep); const _pb = document.getElementById('ckDownloadPdf'); if (_pb) _pb.addEventListener('click', saveQuoteAndDownload); const _sp = document.getElementById('ckSimPay'); if (_sp) _sp.addEventListener('click', function () { _sp.disabled = true; _sp.textContent = 'Placing test order\u2026'; placeOrderSuccess('sim'); }); // Selecting the invoice box puts the 3% into the running total, so the Total // above matches what will actually be charged before anything is placed. const _tbox = document.getElementById('ckTermsBox'); if (_tbox) _tbox.addEventListener('click', function () { _ckSetPayMethod('terms'); _tbox.style.borderColor = '#FFCC00'; }); const _bbox = document.getElementById('ckBankBox'); if (_bbox) _bbox.addEventListener('click', function () { _ckSetPayMethod('bank'); _bbox.style.borderColor = '#FFCC00'; }); const _bp = document.getElementById('ckBankPay'); if (_bp) _bp.addEventListener('click', function () { _bp.disabled = true; _bp.textContent = 'Placing order\u2026'; placeOrderSuccess('bank'); }); const _tp = document.getElementById('ckTermsPay'); if (_tp) _tp.addEventListener('click', function () { _tp.disabled = true; _tp.textContent = 'Placing order\u2026'; placeOrderSuccess('terms'); }); ['payEmail', 'payName', 'payPhone', 'shipName', 'shipApt', 'billName'].forEach(function (id) { const el = document.getElementById(id); if (el) { el.addEventListener('input', function () { if (id === 'payEmail') { try { window._quoterEmail = el.value.trim(); } catch (e) {} } // Clear a complaint the moment it stops being true, but do not start // complaining mid-word. if (el.classList.contains('ck-field-bad')) _showFieldProblem(id); _updatePayGate(); }); el.addEventListener('blur', function () { // Tidy it BEFORE judging it, so the shape they typed is never the // reason they are told it is wrong. if (id === 'payPhone' && el.value.trim()) { var tidy = _formatPhone(el.value); if (tidy !== el.value) { el.value = tidy; try { _updatePayGate(); } catch (e) {} } } if (el.value.trim()) _showFieldProblem(id); }); } }); const bi = document.getElementById('ckBillingInput'); if (bi) bi.addEventListener('input', function () { setTimeout(_updatePayGate, 60); }); const bs = document.getElementById('ckBillingSugg'); if (bs) bs.addEventListener('click', function () { setTimeout(_updatePayGate, 300); }); if (typeof _ckWirePo === 'function') _ckWirePo(); _updatePayGate(); } // ── Checkout address forms (shipping + billing): multi-field, autocomplete-assisted, // Save -> collapse to summary, optional save-to-account. ── let _ckShip = null, _ckBill = null; var _ckSavedAddrs = null; function _ckEscA(v) { return (v == null ? '' : String(v)).replace(/"/g, '"'); } // Makelab ships worldwide via UPS, but the address form had no country field at // all and Places was locked to the US, so an international customer could not // get past step 2 (Christina, 2026-08-01). The list leads with where orders // actually come from; 'Other' keeps the long tail reachable. var CK_COUNTRIES = [ ['US','United States'],['CA','Canada'],['GB','United Kingdom'],['AU','Australia'], ['DE','Germany'],['FR','France'],['NL','Netherlands'],['IE','Ireland'],['IT','Italy'], ['ES','Spain'],['SE','Sweden'],['CH','Switzerland'],['JP','Japan'],['SG','Singapore'], ['NZ','New Zealand'],['MX','Mexico'],['IL','Israel'],['IN','India'],['BR','Brazil'],['KR','South Korea'], ]; // Countries whose postal addresses actually carry a state/province. Elsewhere a // required 'State' field is a dead end, not a validation. var CK_STATE_COUNTRIES = ['US','CA','AU','BR','MX','IN']; function _ckHasState(cc) { return CK_STATE_COUNTRIES.indexOf(String(cc || 'US').toUpperCase()) >= 0; } function _ckCountryFld(id, val) { var cur = String(val || 'US').toUpperCase(); var known = CK_COUNTRIES.some(function (c) { return c[0] === cur; }); var opts = CK_COUNTRIES.map(function (c) { return ''; }).join(''); if (!known && cur) opts += ''; return '
    '; } function _ckFld(id, label, val, opt, ph, locked) { return '
    '; } function _ckAddrForm(prefix, d, withNote, lockAddr) { d = d || {}; // lockAddr (billing): Line 1 IS the search box (consolidated); city/state/zip lock to prevent typos. var head = lockAddr ? '
    Search and pick your address — city, state & postal code fill in and lock to prevent typos. Add apt/suite, name & company below.
    ' : '
    '; var line1 = lockAddr ? '
    ' : _ckFld(prefix + 'Line1', 'Address', d.line1, false, '123 Main St'); return '
    ' + head + _ckFld(prefix + 'Name', 'Full name', d.name, false, 'Jane Doe') + _ckFld(prefix + 'Company', 'Company', d.company, true, '') + line1 + '
    ' + _ckFld(prefix + 'Line2', 'Apt / Suite / Floor', d.line2, true, '') + _ckFld(prefix + 'City', 'City', d.city, false, '', true) + '
    ' + '
    ' + _ckFld(prefix + 'State', 'State / province', d.state, !_ckHasState(d.country), 'NY', true) + _ckFld(prefix + 'Zip', 'Postal code', d.zip, false, '11232', true) + '
    ' + _ckCountryFld(prefix + 'Country', d.country) // Phone is REQUIRED on every address. Couriers and UPS both need a number // for a failed delivery, and an order that reaches the floor without one // costs a phone call to chase. + _ckFld(prefix + 'Phone', 'Phone', d.phone, false, '(555) 555-5555') + (withNote ? _ckFld(prefix + 'Note', 'Delivery instructions', (d.note || (window._quoterPrefs && window._quoterPrefs.delivery_instructions) || ''), true, 'Gate code, where to leave it, who to call…') : '') + '' + ''; } function _ckAddrSummary(prefix, d, label) { d = d || {}; var c = function (x) { return (x == null ? '' : String(x)).replace(/[<>]/g, ''); }; var _cc = String(d.country || 'US').toUpperCase(); var _cn = (typeof CK_COUNTRIES !== 'undefined' && (CK_COUNTRIES.find(function (x) { return x[0] === _cc; }) || [])[1]) || _cc; var rows = [c(d.name), c(d.company), c(d.line1) + (d.line2 ? ', ' + c(d.line2) : ''), [c(d.city), c(d.state), c(d.zip)].filter(Boolean).join(', '), _cc === 'US' ? '' : c(_cn), c(d.phone)].filter(Boolean); return '
    ✓ ' + label + '
    ' + rows.join('
    ') + (d.note ? '
    Note: ' + c(d.note) + '' : '') + '
    '; } function _ckCollect(prefix) { var g = function (id) { var el = document.getElementById(prefix + id); return el ? el.value.trim() : ''; }; var el = document.getElementById(prefix + 'Country'); return { name: g('Name'), company: g('Company'), line1: g('Line1'), line2: g('Line2'), city: g('City'), state: g('State'), zip: g('Zip'), country: (el && el.value) || 'US', phone: g('Phone'), note: g('Note') }; } function _ckValidAddr(d) { return !!(d && d.name && d.line1 && d.city && d.zip && _phoneOk(d.phone) && (!_ckHasState(d.country) || d.state)); } var _CK_REQ = ['Name', 'Line1', 'City', 'State', 'Zip', 'Phone']; // State drops out of the required set for countries that do not have one — a // German customer cannot fill in a province and must not be blocked on it. function _ckReqFor(prefix) { var el = document.getElementById(prefix + 'Country'); var cc = (el && el.value) || 'US'; return _CK_REQ.filter(function (f) { return f !== 'State' || _ckHasState(cc); }); } function _ckFlagMissing(prefix) { var n = 0; _ckReqFor(prefix).forEach(function (f) { var el = document.getElementById(prefix + f); if (!el) return; // Phone is flagged when it is missing OR unusable — a required field that // accepts "n/a" is not a required field. var bad = f === 'Phone' ? !_phoneOk(el.value) : !el.value.trim(); el.classList.toggle('ck-addr-missing', bad); if (bad) n++; }); return n; } function _ckClearMissing(prefix) { _CK_REQ.forEach(function (f) { var el = document.getElementById(prefix + f); if (el) el.classList.remove('ck-addr-missing'); }); } function _ckFlagIfPartial(prefix) { var f = 0, e = 0; _ckReqFor(prefix).forEach(function (k) { var el = document.getElementById(prefix + k); if (el) { if (el.value.trim()) f++; else e++; } }); if (f > 0 && e > 0) _ckFlagMissing(prefix); } try { document.addEventListener('input', function (ev) { var t = ev.target; if (t && t.classList && t.classList.contains('ck-addr-missing') && t.value && t.value.trim()) t.classList.remove('ck-addr-missing'); }); } catch (e) {} // WHICH CARD IS SAVING, because the answer decides where the address lands. // // This always sent { isDefault: true }, and api/save-address.mjs:60 reads that // as the SHIPPING default -- `const wantShipping = !!b.defaultShipping || // !!b.isDefault` -- for compatibility with rows written before billing had a // default of its own. One function serves BOTH cards, so saving a BILLING // address quietly made it the customer's default SHIPPING address. // // Latent until 2026-09-08, when the Places autocomplete was widened to offer PO // boxes for billing (a customer whose card is registered to one could not pay). // A PO box is a perfectly good billing address and an undeliverable shipping // one, so the two together plant an address UPS cannot deliver to into the // shipping slot. chris@faboricus.com has "PO Box 33268" sitting as // is_default_shipping right now, which is what his next checkout prefills. // // The server has taken defaultShipping and defaultBilling separately all along; // only the browser was failing to say which it meant. function _ckSaveAddrAcct(d, which) { try { fetch('/api/save-address', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({}, d, which === 'billing' ? { defaultBilling: true } : { defaultShipping: true })), }).catch(function () {}); } catch (e) {} } function _ckAutofill(prefix, addr) { if (!addr) return; var set = function (id, v) { var el = document.getElementById(prefix + id); if (el && v != null && v !== '') el.value = v; }; set('Line1', addr.line1 || ''); set('City', addr.city || ''); set('State', addr.state || ''); set('Zip', addr.zip || ''); // A picked suggestion decides the country, so it OVERWRITES rather than // fills-if-empty — otherwise a Berlin address stays labelled United States. var cel = document.getElementById(prefix + 'Country'); if (cel && addr.country) { cel.value = String(addr.country).toUpperCase(); _ckClearMissing(prefix); } } function _ckLoadSavedAddrs(cb) { if (_ckSavedAddrs !== null) { cb(_ckSavedAddrs); return; } try { fetch('/api/addresses').then(function (r) { return r.json(); }).then(function (j) { _ckSavedAddrs = (j && j.addresses) || []; cb(_ckSavedAddrs); }).catch(function () { _ckSavedAddrs = []; cb([]); }); } catch (e) { _ckSavedAddrs = []; cb([]); } } function _ckFillAll(prefix, a) { if (!a) return; var set = function (id, v) { var el = document.getElementById(prefix + id); if (el) el.value = (v == null ? '' : v); }; set('Name', a.name); set('Company', a.company); set('Line1', a.line1); set('Line2', a.line2); set('City', a.city); set('State', a.state); set('Zip', a.zip); set('Phone', a.phone); try { _ckClearMissing(prefix); } catch (e) {} } // onPick lets a caller do something other than fill a full address form — the // courier step has a single destination input, not a form. function _ckMountSavedPicker(prefix, onPick) { try { if (!document.getElementById(prefix + 'Saved')) return; _ckLoadSavedAddrs(function (list) { try { var host = document.getElementById(prefix + 'Saved'); if (!host) return; if (!list || !list.length) { host.innerHTML = ''; return; } var chips = list.map(function (a, i) { var lbl = a.name || a.line1 || 'Saved address'; var sub = [a.line1, a.city].filter(Boolean).join(', '); return ''; }).join(''); host.innerHTML = '
    Use a saved address
    ' + chips + '
    '; host.querySelectorAll('.ck-saved-chip').forEach(function (b) { b.addEventListener('click', function () { var a = list[+b.getAttribute('data-i')]; if (typeof onPick === 'function') onPick(a); else _ckFillAll(prefix, a); }); }); } catch (e) {} }); } catch (e) {} } // AN ADDRESS CHANGED ON BILLING & SHIPPING IS RE-QUOTED THERE, NOT DROPPED. // // Christina, 2026-09-28: priyanshu@viatouchmedia.com could not pay staff quote // Q-16024-1 -- two card refusals, ship_rate_invalid, the browser holding // { rateId: null, shipmentId: null }. The UPS rate is picked on Lead Time & // Delivery; saving a shipping address with a different ZIP here threw that // choice away and said "re-select your UPS option" in a toast, on a step with // no UPS options on it. Pay stayed open, the server refused a shipping order // with no rate, and she read "Your shipping rate expired". The third try went // through only because she went round the whole checkout again. // // So the service she chose is carried to the new address: fetchCarrierRates // re-points it at the same service on a fresh shipment (new id, new price) and // the payment step is repainted, so the total on screen is the total charged. // Only when that service is not offered at the new address is she sent back to // pick one -- and told so. async function _ckRequoteAfterAddressChange(prev) { const seq = ++_ckRequoteSeq; // A second save while the first is still out: ckSelectedRate is already // cleared, so the choice being carried is the one to carry again. prev = prev || _ckRequotePrev; _ckRequotePrev = prev || null; // Nothing payable while the answer is out: the old id belongs to the old // shipment, and the old price to the old ZIP. ckSelectedRate = null; _ckShipmentId = null; _ckRequoting = true; _ckRepointFrom = prev || null; try { if (typeof _updatePayGate === 'function') _updatePayGate(); } catch (e) {} try { await fetchCarrierRates(); } catch (e) {} // A later address change owns the outcome. if (seq !== _ckRequoteSeq) return; _ckRequoting = false; _ckRepointFrom = null; _ckRequotePrev = null; // Providing your own label does not depend on the address. The re-point // leaves it alone only when it is still selected, and it was cleared above, // so it is put back -- as the row on the new list, so the highlight agrees. if (!ckSelectedRate && prev && prev._ownLabel && Array.isArray(_ckLastRates)) { const _own = _ckLastRates.filter(function (rt) { return rt && rt._ownLabel; })[0] || null; if (_own) { ckSelectedRate = _own; try { const _rows = document.getElementById('ckCarrierRows'); const _i = _ckLastRates.indexOf(_own); if (_rows) _rows.querySelectorAll('.ck-row').forEach(function (r) { r.classList.toggle('active', parseInt(r.dataset.rate, 10) === _i); }); const _cur = document.getElementById('curDelivery'); if (_cur) { _cur.textContent = _own._label; _cur.classList.remove('empty'); } } catch (e) {} } } if (ckSelectedRate) { _ckRerenderPayKeepingTyped(); if (typeof showQuoterToast === 'function') { showQuoterToast('Shipping updated for ' + (_ckAddrZip || 'the new address') + ': ' + (ckSelectedRate._label || 'UPS') + (ckSelectedRate._freight || ckSelectedRate._ownLabel ? '' : ' $' + Number(ckSelectedRate.rate || 0).toFixed(2)) + '.'); } return; } try { const box = document.getElementById('ckAddrInput'); if (box && _ckAddr && _ckAddr.formatted) box.value = _ckAddr.formatted; } catch (e) {} backToLeadStep(); try { if (typeof ckOpen === 'function') ckOpen('delivery'); } catch (e) {} try { if (typeof updateContinueState === 'function') updateContinueState(); } catch (e) {} if (typeof showQuoterToast === 'function') showQuoterToast('Pick a UPS shipping option for your new address, then continue.'); } // THE CUSTOMER'S PURCHASE ORDER NUMBER. // // Christina, 2026-09-30: "PO should definitely be a field......" Huhtamaki's // purchasing department paid order 11811 by card after emailing us PO 1020118, // because nothing on this page could take it. The floor has always had a PO // column (amfg_parts_orders.po_number -- the order page, dispatch emails, // labels and the portal all print it); the checkout simply never sent one. // // HELD IN STATE, NOT ONLY IN THE BOX. renderPaymentStep rebuilds this whole // step from state -- on the 3:30 cutoff tick, a credit toggle, a credit_changed // refusal and an address re-quote -- and a value that lived only in the input // would be blanked by every one of them. It is also kept in sessionStorage for // THIS checkout (the quote being bought, or the cart), so reloading the page // keeps what was typed: a quote link reopens straight into checkout. It is // forgotten the moment the order is placed, so the next order never inherits it. // // The text rule is lib/po-number.mjs cleanPoNumber, restated here because the // page cannot import it: trimmed, spaces collapsed, control characters, angle // brackets and unpaired surrogates out, 60 UTF-16 units at most without // cutting a pair, empty means none. // tests/checkout-po-number.test.mjs runs both copies and requires them to agree. var _ckPoNumber = ''; var _ckPoKeyUsed = null; function _ckCleanPo(v) { if (v == null || (typeof v !== 'string' && typeof v !== 'number')) return null; // An unpaired surrogate out, and the 60 never cuts a pair in two: the server // writes this into jsonb, which refuses the whole row over half a character. var s = String(v).replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/[<>]/g, '') .replace(/[\uD800-\uDFFF]/gu, '').replace(/\s+/g, ' ').trim().slice(0, 60); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); s = s.trim(); return s || null; } // What is typed now: the box when it is on screen, the held value when it is not. function _ckPoValue() { var raw = null; try { var el = document.getElementById('payPo'); if (el && typeof el.value === 'string') raw = el.value; } catch (e) {} if (raw == null) raw = _ckPoNumber; return _ckCleanPo(raw); } // Whether the account requires one (pay-mode says so), and whether it is // missing -- the one question the gate, the box and the reason all ask. function _ckPoRequired() { return !!(typeof window !== 'undefined' && window._poRequired); } function _ckPoMissing() { return _ckPoRequired() && !_ckPoValue(); } // One entry per checkout: the quote being bought, or the cart. function _ckPoStoreKey() { var scope = 'cart'; try { var qs = new URLSearchParams((typeof location !== 'undefined' && location.search) || ''); var lq = (typeof window !== 'undefined' && window.__lockedQuote) || null; var no = (lq && lq.no) || qs.get('quote') || (typeof window !== 'undefined' && window.__resumedQuoteNo) || qs.get('resume') || ''; if (no) scope = String(no).slice(0, 40); } catch (e) {} return 'mlq_po:' + scope; } function _ckPoRemember() { try { var key = _ckPoStoreKey(); var v = String(_ckPoNumber == null ? '' : _ckPoNumber).slice(0, 60); if (v.trim()) { sessionStorage.setItem(key, v); _ckPoKeyUsed = key; } else sessionStorage.removeItem(key); } catch (e) { /* storage refused (a private window): it still lives in state for this page */ } } function _ckPoRestore() { if (_ckPoNumber) return; try { var v = sessionStorage.getItem(_ckPoStoreKey()); if (v) _ckPoNumber = String(v).slice(0, 60); } catch (e) {} } function _ckPoForget() { _ckPoNumber = ''; try { sessionStorage.removeItem(_ckPoStoreKey()); if (_ckPoKeyUsed) sessionStorage.removeItem(_ckPoKeyUsed); } catch (e) {} _ckPoKeyUsed = null; } // EVERY PO this tab is holding. A redirect payment (Cash App, Amazon Pay, // Klarna) comes back to location.pathname with the query string gone, so the // page that confirms it no longer knows which quote was bought and // _ckPoForget's key is the cart's, not the quote's: the quote's PO stayed // behind for the next time it was opened. A tab holds one checkout at a time, // and this one has just been paid for. function _ckPoForgetAll() { _ckPoNumber = ''; _ckPoKeyUsed = null; try { var drop = []; for (var i = 0; i < sessionStorage.length; i++) { var k = sessionStorage.key(i); if (k && k.indexOf('mlq_po:') === 0) drop.push(k); } drop.forEach(function (k) { sessionStorage.removeItem(k); }); } catch (e) { /* storage refused: there is nothing kept to forget */ } } // The box, in the contact card beside Company, and built exactly as Company is. // Optional unless the account requires one; then the label drops "(optional)" // and a note under the box says why, in the muted note style the page already // uses under a locked field. function _ckPoFieldHtml() { _ckPoRestore(); var req = _ckPoRequired(); return '
    ' + '' + (req ? 'Your company requires a PO number on every order.' : '') + '
    '; } // What the customer checks it against before paying: a line in both order // summaries, present only when there is a PO -- a labelled blank reads as // something lost rather than something absent. function _ckPoReviewHtml() { var po = _ckPoValue(); if (!po) return ''; return '
    PO number' + _escHtml(po) + '
    '; } function _ckRenderPoReview() { var html = _ckPoReviewHtml(); ['ckPoReview', 'ckPoReview2'].forEach(function (id) { var el = document.getElementById(id); if (el) el.innerHTML = html; }); } function _ckWirePo() { var el = document.getElementById('payPo'); if (!el) return; el.addEventListener('input', function () { _ckPoNumber = el.value; _ckPoRemember(); _ckRenderPoReview(); // Clear a complaint the moment it stops being true, and let the gate open. try { if (el.classList && el.classList.contains('ck-field-bad') && typeof _showFieldProblem === 'function') _showFieldProblem('payPo'); } catch (e) {} if (typeof _updatePayGate === 'function') _updatePayGate(); }); el.addEventListener('change', function () { // Shown as it will be sent, so the box, the review and the order agree. var clean = _ckCleanPo(el.value) || ''; if (clean !== el.value) el.value = clean; _ckPoNumber = clean; _ckPoRemember(); _ckRenderPoReview(); // A CARD PAYMENT ALREADY SET UP CARRIES THE PO IT WAS SET UP WITH. The // Stripe webhook can build the floor order from that copy before the // browser's own order arrives, so a PO changed afterwards sets the payment // up again rather than leaving the old one to reach the floor. try { if (document.querySelector('#ckCardElement iframe') && typeof _ckRerenderPayKeepingTyped === 'function') _ckRerenderPayKeepingTyped(); } catch (e) {} }); _ckRenderPoReview(); } // renderPaymentStep builds this step from state, and the name, phone and // company boxes are not state until the order is placed -- so a repaint would // blank what the customer has just typed. Carried across it. function _ckRerenderPayKeepingTyped() { const ids = ['payName', 'payPhone', 'payCompany']; const kept = {}; ids.forEach(function (id) { const el = document.getElementById(id); if (el && el.value) kept[id] = el.value; }); // A billing address still being typed lives only in its inputs, and // _renderBillCard rebuilds the form from _ckBill. Held there, unsaved, so // she still confirms it. if (orderDispatch === 'pickup' || !ckBillingSameAsShip) { if (!(_ckBill && _ckBill.saved) && document.getElementById('ckbillLine1')) { try { _ckBill = Object.assign({}, _ckBill || {}, _ckCollect('ckbill'), { saved: false }); } catch (e) {} } } renderPaymentStep(); ids.forEach(function (id) { const el = document.getElementById(id); if (el && !el.value && kept[id]) el.value = kept[id]; }); try { if (typeof _updatePayGate === 'function') _updatePayGate(); } catch (e) {} } function _renderShipCard() { var host = document.getElementById('ckShipBody'); if (!host) return; if (orderDispatch === 'pickup') { host.innerHTML = '
    Local pickup
    Makelab factory · 13 42nd St, Brooklyn, NY 11232
    '; var pe = document.getElementById('ckPayEditAddr'); if (pe) pe.addEventListener('click', backToLeadStep); _updatePayGate(); return; } if (!_ckShip && _ckAddr) _ckShip = { name: '', line1: _ckAddr.line1 || '', line2: '', city: _ckAddr.city || '', state: _ckAddr.state || '', zip: _ckAddr.zip || _ckAddrZip || '', note: '', saved: false }; if (_ckShip && _ckShip.saved) { host.innerHTML = _ckAddrSummary('ckship', _ckShip, 'Shipping to'); var ed = document.getElementById('ckshipEdit'); if (ed) ed.addEventListener('click', function () { _ckShip.saved = false; _renderShipCard(); }); _updatePayGate(); return; } host.innerHTML = _ckAddrForm('ckship', _ckShip || {}, true); _ckMountSavedPicker('ckship'); wireAddressAutocomplete(document.getElementById('ckshipSearch'), document.getElementById('ckshipSugg'), function (addr) { _ckAutofill('ckship', addr); }); _ckFlagIfPartial('ckship'); document.getElementById('ckshipSave').addEventListener('click', function () { var d = _ckCollect('ckship'); if (!_ckValidAddr(d)) { _ckFlagMissing('ckship'); if (typeof showQuoterToast === 'function') showQuoterToast('Fill the highlighted fields to continue.'); return; } _ckClearMissing('ckship'); // NO SUITE IS AN ANSWER, BUT IT HAS TO BE GIVEN. // // Christina, 2026-09-02: "when entering the shipping information, can you // have them double confirm there is no suite number if this area is blank?" // // A blank Apt / Suite / Floor is indistinguishable from a skipped one, and // the difference is a parcel that reaches the building and not the person. // Asked once, then remembered: the second press is the confirmation, and it // is scoped to the address they are looking at, so editing to a different // one asks again. if (!String(d.line2 || '').trim()) { var _key = [d.line1, d.zip].join('|').toLowerCase(); if (window.__ckNoSuiteOk !== _key) { window.__ckNoSuiteOk = _key; var _l2 = document.getElementById('ckshipLine2'); if (_l2) { try { _l2.classList.add('ck-addr-missing'); _l2.focus(); } catch (e) {} } if (typeof showQuoterToast === 'function') { showQuoterToast('No apt, suite or floor at this address? Press save again to confirm.'); } return; } } try { var _l2b = document.getElementById('ckshipLine2'); if (_l2b) _l2b.classList.remove('ck-addr-missing'); } catch (e) {} d.saved = true; _ckShip = d; _logCheckoutSnapshot(); var _prevRateZip = _ckAddrZip; var _prevCountry = (_ckAddr && _ckAddr.country) || 'US'; _ckAddr = Object.assign({}, _ckAddr || {}, { line1: d.line1, city: d.city, state: d.state, zip: d.zip, country: d.country || 'US', formatted: [d.line1, d.line2, d.city, d.state, d.zip, d.country].filter(Boolean).join(', ') }); _ckAddrZip = d.zip; if (orderDispatch === 'shipping' && (ckSelectedRate || _ckRequoting) && ((_prevRateZip && d.zip !== _prevRateZip) || (d.country || 'US') !== _prevCountry)) { // The selected UPS rate was quoted for the previous ZIP. Re-quote the same // service for this one -- see _ckRequoteAfterAddressChange. _ckRequoteAfterAddressChange(ckSelectedRate); } window._orderForm = Object.assign(window._orderForm || {}, { shipName: d.name, shipApt: d.line2 }); if ((document.getElementById('ckshipSaveAcct') || {}).checked) _ckSaveAddrAcct(d, 'shipping'); if (typeof ckBillingSameAsShip !== 'undefined' && ckBillingSameAsShip) _renderBillCard(); _renderShipCard(); }); _updatePayGate(); } function _renderBillCard() { var host = document.getElementById('ckBillBody'); if (!host) return; var isPickup = orderDispatch === 'pickup'; var sameWrap = isPickup ? '' : ''; var wireSame = function () { var sc = document.getElementById('ckBillingSame'); if (sc) sc.addEventListener('change', function () { ckBillingSameAsShip = sc.checked; _renderBillCard(); }); }; if (!isPickup && ckBillingSameAsShip) { host.innerHTML = sameWrap + '
    Using your shipping address for billing.
    '; wireSame(); _updatePayGate(); return; } if (_ckBill && _ckBill.saved) { host.innerHTML = sameWrap + _ckAddrSummary('ckbill', _ckBill, 'Billing to'); wireSame(); var ed = document.getElementById('ckbillEdit'); if (ed) ed.addEventListener('click', function () { _ckBill.saved = false; _renderBillCard(); }); _updatePayGate(); return; } host.innerHTML = sameWrap + '
    ' + _ckAddrForm('ckbill', _ckBill || {}, false, true) + '
    '; _ckMountSavedPicker('ckbill'); wireSame(); wireAddressAutocomplete(document.getElementById('ckbillLine1'), document.getElementById('ckbillSugg'), function (addr) { _ckAutofill('ckbill', addr); }, true, 'billing'); document.getElementById('ckbillSave').addEventListener('click', function () { var d = _ckCollect('ckbill'); if (!_ckValidAddr(d)) { _ckFlagMissing('ckbill'); if (typeof showQuoterToast === 'function') showQuoterToast('Fill the highlighted fields to continue.'); return; } _ckClearMissing('ckbill'); d.saved = true; _ckBill = d; _logCheckoutSnapshot(); _ckBillingAddr = Object.assign({}, _ckBillingAddr || {}, { zip: d.zip, formatted: [d.line1, d.line2, d.city, d.state, d.zip].filter(Boolean).join(', ') }); window._orderForm = Object.assign(window._orderForm || {}, { billName: d.name, ckBillingInput: [d.line1, d.line2, d.city, d.state, d.zip].filter(Boolean).join(', ') }); if ((document.getElementById('ckbillSaveAcct') || {}).checked) _ckSaveAddrAcct(d, 'billing'); _renderBillCard(); }); _updatePayGate(); } function _ckPrefillAddresses() { try { // LOCAL DEV: no server session on localhost, so address lookup/API 401s. Seed a sample // address + valid email so the flow is testable. Gated to localhost -> never runs in prod. var _devLocal = (typeof location !== 'undefined' && ['localhost','127.0.0.1','[::1]','0.0.0.0'].indexOf(location.hostname) >= 0) || (typeof window !== 'undefined' && window._quoterEmail === 'dev@localhost'); if (_devLocal) { try { var _rxEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; if (!window._quoterEmail || !_rxEmail.test(window._quoterEmail)) window._quoterEmail = 'dev@makelab.com'; var _pe = document.getElementById('payEmail'); if (_pe && (!_pe.value || !_rxEmail.test(_pe.value))) _pe.value = window._quoterEmail; } catch (e) {} var _seed = { name: 'Dev Tester', company: '', line1: '350 5th Ave', line2: '', city: 'New York', state: 'NY', zip: '10118', phone: '(212) 555-0100' }; if (!_ckShip && orderDispatch !== 'pickup') { _ckShip = Object.assign({ note: '', saved: true }, _seed); _renderShipCard(); } if (!_ckBill && (orderDispatch === 'pickup' || !ckBillingSameAsShip)) { _ckBill = Object.assign({ saved: true }, _seed); _renderBillCard(); } if (typeof _updatePayGate === 'function') _updatePayGate(); return; } fetch('/api/addresses').then(function (r) { return r.json(); }).then(function (j) { var a = (j && j.addresses && j.addresses[0]) || null; if (!a) return; if (!_ckShip && orderDispatch !== 'pickup') { _ckShip = { name: a.name || '', company: a.company || '', line1: a.line1 || '', line2: a.line2 || '', city: a.city || '', state: a.state || '', zip: a.zip || '', phone: a.phone || '', note: '', saved: false }; _renderShipCard(); } if (!_ckBill && (orderDispatch === 'pickup' || !ckBillingSameAsShip)) { _ckBill = { name: a.name || '', company: a.company || '', line1: a.line1 || '', line2: a.line2 || '', city: a.city || '', state: a.state || '', zip: a.zip || '', phone: a.phone || '', saved: false }; _renderBillCard(); } }).catch(function () {}); } catch (e) {} } // The email is the account. The order, the receipt, the portal and every future // login all key off it, so letting someone type a different one at checkout // creates an order the buyer cannot see in their own account. Same for a name we // already hold: it is on the account, and the place to change it is the account. // // Locked by making the field readonly and saying why, rather than hiding it — // the customer still needs to SEE which address the receipt is going to. function _ckLockField(id, value, why) { var el = document.getElementById(id); if (!el || !value) return; el.value = value; el.readOnly = true; el.setAttribute('aria-readonly', 'true'); el.tabIndex = -1; if (el.parentNode && !el.parentNode.querySelector('.ck-locked-note')) { var n = document.createElement('span'); n.className = 'ck-locked-note'; n.textContent = why; el.parentNode.appendChild(n); } } function _ckPrefillContact() { try { window.mlqAccount.get().then(function (j) { try { var prefs = (j && j.profile && j.profile.preferences) || {}; var co = (j && j.company && j.company.client && j.company.client.name) || ''; var set = function (id, v) { var el = document.getElementById(id); if (el && !el.value && v) el.value = v; }; set('payPhone', prefs.phone); set('payCompany', co); var acctEmail = (j && j.profile && j.profile.email) || window._quoterEmail || ''; _ckLockField('payEmail', acctEmail, 'This is the account you are signed in with.'); _ckLockField('rfqEmail', acctEmail, 'This is the account you are signed in with.'); // Prefilled, NOT locked. It used to be read-only with "change it in your // profile", which meant a customer whose name or company had changed // could not correct it at the one moment they were thinking about it. // The order now writes back to the account (Christina, 2026-08-01), so // editing here is how you update your details. set('payName', prefs.full_name); set('rfqName', prefs.full_name); set('payCompany', prefs.company_name); } catch (e) {} }).catch(function () {}); } catch (e) {} } function wireAddrForms() { _renderShipCard(); _renderBillCard(); _ckPrefillAddresses(); _ckPrefillContact(); var b3 = document.getElementById('ckBackBottom3'); if (b3) b3.addEventListener('click', backToLeadStep); } // WHAT IS LEFT TO PAY, AND HOW THE ORDER IS THEN PLACED. // // This used to require appliedCents > 0, so it recognised store credit covering // an order but NOT a discount that left nothing to pay. A 100% voucher gave a // $0.00 total, no "nothing to pay" button, and a Stripe card element that // cannot take $0 -- so the order could not be placed by ANY route. Reported // 2026-08-31 by a BTHS Science Olympiad customer: "I cannot attach a card // because the total is $0.00." // // Store credit is REDEEMED and a free order redeems nothing, so the two are not // the same payment: sending 'credit' for a voucher order would put "Store // credit" on the receipt of an account that spent none. function _ckPayCoverage(split) { var s = split || {}; var applied = Number(s.appliedCents) || 0; var remainder = Number(s.remainderCents) || 0; var nothingToPay = remainder === 0; return { nothingToPay: nothingToPay, byCredit: nothingToPay && applied > 0, method: nothingToPay ? (applied > 0 ? 'credit' : 'free') : null, }; } function renderPaymentStep() { const wrap = document.getElementById('ckStep3'); if (!wrap) return; try { _warmStripeJs(); } catch (e) {} const applied = _ckAppliedParts(); const _paySplit = _ckTotalsSplit(applied, orderDeliveryTier, ckLeadChosen); const mins = _paySplit.base; const _payLeadDelta = _paySplit.leadDelta; const tier = _ckSelectedTier(); const ready = ckLeadChosen ? addBusinessDays(productionStart(), tier.businessDays) : null; const shipCost = _ckShipCost(); const _orderTotal = mins.grandTotal + _payLeadDelta; const _disc = _ckDiscountAmount(_orderTotal); // The 3% invoicing fee, on the LAST summary the customer reads before // committing. It was computed on step 2 only — and step 2 lives inside // #ckStep2, which goToPaymentStep() has already hidden by the time they click // "place order · invoice me". So a Net-30 customer approved a total 3% below // what api/place-order.mjs then charged: $758.63 on screen, $781.39 on the // invoice. // // _ckInvoiceFeeAmount returns 0 unless the method is 'terms', so card and // PayPal totals are unchanged. `grand` also labels the pay buttons and feeds // _wirePayChoice, which is exactly why it has to be the real number. const _pay = _ckPayTotals(_orderTotal, shipCost, _disc, _ckPayMethod); const _payFee = _pay.fee; const _tax = _pay.tax; const grand = _pay.grand; // Recorded HERE, beside the number the page is about to render, rather than // inside the pay-choice wiring -- a path that renders a total without wiring // the buttons would otherwise send a stale one. _ckShownGrand = grand; // AND THE TOTAL FOR EACH KIND OF METHOD, not only the one selected when this // was drawn. Humanscale, 2026-09-29: this step is drawn before any method is // picked, so _ckShownGrand had no invoicing fee in it; "Place order · invoice // me" sits INSIDE the invoice box, so its click placed the order before the // box had even selected terms, and selecting terms never redrew this. Four // total_mismatch refusals in six minutes, every one sending the fee-less // figure. The reverse held too: drawn with terms selected, a bank or card // order sent a fee the server does not charge on them. _ckShownCentsFor picks // by the method actually being placed. const _termsPay = _ckPayTotals(_orderTotal, shipCost, _disc, 'terms'); const _termsFee = _termsPay.fee; _ckShownTotals = { terms: _termsPay.grand, plain: _ckPayTotals(_orderTotal, shipCost, _disc, null).grand }; // EVERY WAY OF PAYING NOW PRICES WITHOUT THE INVOICING FEE. // // `grand` is the total for the method SELECTED when this step was last // drawn, so after the invoice box was ticked and the step redrawn (the credit // toggle, an address save, going back and forth, the cut-off tick) it carries // the 3% fee. It fed the card: the pay button's amount, the payment-intent // comparison, and the "yes" a re-ask records. _persistOrder then looked that // yes up under the card's own figure, which has no fee -- so the customer's // confirmation was not found and the page posted a figure they had not // confirmed (found in the independent check of the re-ask, 2026-09-30). The // card, PayPal, store credit and a bank transfer are never charged the fee // (api/place-order.mjs), so they all read the plain total, the same figure // _ckShownCentsFor sends for them. The fee-inclusive total stays on the // invoice box and in the summary while invoicing is the method shown. const _payNow = _ckShownTotals.plain; // Credit is settled here once so the card button, the PayPal block and the // covered-in-full path all price from the same split. const _credSplit = _ckCreditOn() ? _ckCreditSplit(Math.round(_payNow * 100)) : { appliedCents: 0, remainderCents: Math.round(_payNow * 100) }; const _cov = _ckPayCoverage(_credSplit); // Covered in full, by store credit OR by a discount that left nothing owing. const _credCovers = _cov.nothingToPay; // The handler that places it runs in another function; this is how it knows // which of the two it is. window._ckCoverMethod = _cov.method; const _coverMsg = _cov.byCredit ? ('Your store credit of $' + formatPrice(_credSplit.appliedCents / 100) + ' covers this order in full. No card needed.' + (_ckCreditBalance > _credSplit.appliedCents ? ' $' + formatPrice((_ckCreditBalance - _credSplit.appliedCents) / 100) + ' stays on your account.' : '')) : 'Your discount covers this order in full. There is nothing to pay.'; const _coverBtn = _cov.byCredit ? 'Place order \u00b7 paid with store credit' : 'Place order \u00b7 nothing to pay'; const _due = _credSplit.remainderCents / 100; let delLine = 'Delivery', delVal = ''; if (orderDispatch === 'pickup') { delLine = 'Local pickup — Brooklyn factory'; delVal = 'Free'; } else if (orderDispatch === 'courier') { delLine = 'Courier'; delVal = '$15.00'; } // Freight reads TBD here as it does on step 2 and the confirmation -- this // line printed '$0.00' for a cost that is quoted later, which is a final- // looking price for something that is about to be added. else if (orderDispatch === 'shipping' && ckSelectedRate && ckSelectedRate._freight) { delLine = (ckSelectedRate._label || 'Freight') + ' — quoted separately'; delVal = 'TBD'; } else if (orderDispatch === 'shipping' && ckSelectedRate) { delLine = (ckSelectedRate._label || ('UPS ' + ckSelectedRate.service)); delVal = '$' + ckSelectedRate.rate.toFixed(2); } if (_ckShipCreditOn() && delVal && delVal !== 'TBD') { delLine += ' — free-shipping credit applied'; delVal = 'Free'; } const isPickup = orderDispatch === 'pickup'; const a = _ckAddr || {}; const esc = v => (v == null ? '' : String(v)).replace(/"/g, '"'); const sumRows = _ckSummaryRows(applied); wrap.innerHTML = `
    ⏱ --:--:-- left — place your order by 3:30 PM ET today to keep these dates.
    Provide contact information
    ${(typeof _ckPoFieldHtml === 'function' && _ckPoFieldHtml()) || ''}
    ${isPickup ? 'Provide pickup details' : 'Provide shipping info'}
    Provide billing info
    `; const _totalsHtml = `
    Subtotal${_rfqTilde()}$${formatPrice(mins.subtotal)}
    ` + (mins.adjustmentTotal > 0 ? `
    Material minimum+$${formatPrice(mins.adjustmentTotal)}
    ` : '') + `
    Lead time${ckLeadChosen ? tier.name + ' · ' + tier.businessDays + ' biz day' + (tier.businessDays === 1 ? '' : 's') + ' · ready ' + _ckDateFmt(ready) : 'Not selected'}${ckLeadChosen ? _ckLeadAmtText(_payLeadDelta) : '\u2014'}
    ` + `
    Delivery${delLine}${delVal}
    ` + (_disc > 0 ? `
    Discount (${ckDiscount.code})−$${formatPrice(_disc)}
    ` : '') + (_payFee > 0 ? `
    Invoicing fee (3%)$${formatPrice(_payFee)}
    ` : '') + `
    ${_ckTaxLabel()}${_rfqTilde()}$${formatPrice(_tax)}
    Total${_rfqTilde()}$${formatPrice(grand)}
    ` + _ckCreditRowsHtml(grand, { toggle: true }); var _tp1 = document.getElementById('ckTotalsPay'); if (_tp1) _tp1.innerHTML = _totalsHtml; var _tp2 = document.getElementById('ckTotalsPay2'); if (_tp2) _tp2.innerHTML = _totalsHtml; wirePayExtras(); wireAddrForms(); _wirePayChoice(_payNow); var _cp = document.getElementById('ckCreditPay'); if (_cp) _cp.addEventListener('click', function () { _cp.disabled = true; _cp.textContent = 'Placing order\u2026'; placeOrderSuccess(window._ckCoverMethod || 'credit'); }); var _cont2 = document.getElementById('ckBillingContinue'); if (_cont2) _cont2.addEventListener('click', function () { if (typeof _payReady === 'function' && !_payReady()) { _updatePayGate(); return; } // Shipping is being re-quoted for a new address: the total is not known yet. if (_ckRequoting) { if (typeof showQuoterToast === 'function') showQuoterToast('One moment, updating shipping for the new address.'); return; } goToCheckoutSub(); }); var _b2b = document.getElementById('ckBackToBilling'); if (_b2b) _b2b.addEventListener('click', backToBillingSub); if (typeof renderCheckoutTimeline === 'function') renderCheckoutTimeline(); if (typeof renderLeadCalendar === 'function') renderLeadCalendar(); } let _stripe = null, _stripeElements = null; function _payCartPayload() { // Free text a customer typed for a custom colour match, on its way to printed // shop paperwork -- so it is trimmed and bounded here rather than sent as // typed. 60 characters holds "PMS 485 C" and a short description ("matte // pantone 2945 C") without wrapping the colour column on a ticket, a bin // label or a packing slip, and nothing downstream truncates for us. // Empty becomes null so the server can tell "no text" from "text" and never // writes a blank colour onto the floor row in place of what it writes today. // // Deliberately INSIDE _payCartPayload rather than beside it. Several test // harnesses lift this one function out of the served page and run it in a vm // (freight-review, pay-payload, transit-days and others); a free identifier // next to it fails every one of them with a ReferenceError that has nothing // to do with what they are checking. Nothing else in the page needs it. function _ckColorCustom(v) { var s = String(v == null ? '' : v).trim().slice(0, 60).trim(); return s || null; } // EVERYTHING THE CUSTOMER TOLD US ABOUT THE PART, ON ITS WAY OUT OF THE // BROWSER. // // The pins they dropped on the model, the faces they painted as cosmetic, // the note they typed under them, and the face they chose as the build-plate // bottom. All four were shown back to them at checkout and printed on the // quote PDF (_ckPartNotes), and then this function -- the ONLY path out of // the browser, feeding payment-intent, PayPal, place-order, the RFQ and // save-quote -- did not carry any of them. lib/production-order.mjs has read // p.notes, p.annotations, p.cosmetic and p.orientation the whole time, so the // columns were written null on every order the quoter has ever placed: // production_notes, annotations, cosmetic and orientation are 0 rows across // 35,369 parts. Christina, 2026-09-07: "fix the part notes and orientation // stuff". // // SAFE IN A BARE SANDBOX, for the same reason _ckColorCustom lives inside this // function: several harnesses lift _payCartPayload out of the page and run it // in a vm (tests/pay-payload.test.mjs and others), where the page's // `annotations` array, _annotPreset and _ckPartNotes do not exist. A sandbox // that supplies none of them gets nulls, which is exactly the old behaviour. // // WHAT ACTUALLY GUARANTEES THAT is the try/catch around each block below, not // the typeof checks -- a ReferenceError is an exception like any other and is // caught. Said plainly because it was measured: deleting the typeof on // `annotations` leaves every test in // tests/what-the-customer-told-us-reaches-the-floor.test.mjs green. The // typeof stays as the statement of intent; the catch is the guard. function _ckPartExtras(p) { var pins = []; try { var all = (typeof annotations !== 'undefined' && annotations) || []; pins = all.filter(function (a) { return a && a.partId === p.id; }).map(function (a) { var label = 'Note'; try { if (typeof _annotPreset === 'function') label = (_annotPreset(a.presetKey) || {}).label || 'Note'; } catch (e) {} return { num: a.num == null ? null : Number(a.num), preset: a.presetKey || null, label: label, note: String(a.note == null ? '' : a.note).trim().slice(0, 500) || null, // Where on the model they put it, so a viewer can find the pin again. at: a.localPt && typeof a.localPt === 'object' ? { x: Number(a.localPt.x) || 0, y: Number(a.localPt.y) || 0, z: Number(a.localPt.z) || 0 } : null, }; }); } catch (e) { pins = []; } // WHAT A REBUILT PART CARRIES INSTEAD OF LIVE PINS. On a resumed or shared // quote the page rebuilds the part from storage and does NOT re-pin the // model, so the live `annotations` array is empty and everything the // customer marked would be dropped a second time. The stored value is the // answer when the tools have nothing to say. Live state still wins, so a // pin added after resuming replaces the stored set rather than merging into // it -- one source, never a half-merge nobody can reason about. if (!pins.length && Array.isArray(p.annotations) && p.annotations.length) pins = p.annotations; // FACE COUNT AND THE NOTE, NOT THE FACE INDICES. _cosmetic.tris is a Set of // triangle indices and can run to thousands on a dense mesh; nothing in // production redraws them today, and the two things the floor acts on are // "this part has cosmetic faces" and what the customer said about them. // Carrying the indices is a bigger change than this fix and can be added // without moving anything already written here. var cosmetic = null; try { var c = p._cosmetic; var faces = (c && c.tris && typeof c.tris.size === 'number') ? c.tris.size : 0; var cnote = String((c && c.note) == null ? '' : c.note).trim().slice(0, 500) || null; if (faces > 0 || cnote) cosmetic = { faces: faces, note: cnote }; } catch (e) { cosmetic = null; } // Same fallback, same reason: a rebuilt part has no painted faces to read. if (!cosmetic && p.cosmetic && typeof p.cosmetic === 'object') cosmetic = p.cosmetic; // ADVISORY, AND RECORDED AS SUCH. Christina, 2026-09-07, asked for the // customer's chosen orientation to be advisory with any override recorded // rather than binding on the shop -- so what travels is the fact that they // set one plus the quaternion to reproduce it, and nothing here promises // the part will be printed that way. var orientation = null; try { var q = p.mesh && p.mesh.quaternion; if (p._origQuat && q && !q.equals(p._origQuat)) { orientation = { custom: true, quaternion: [Number(q.x) || 0, Number(q.y) || 0, Number(q.z) || 0, Number(q.w) || 0] }; } } catch (e) { orientation = null; } // Same again. A rebuilt mesh sits in its uploaded orientation, so the // quaternion comparison finds no custom orientation even when the customer // chose one -- the stored answer is the only one left. if (!orientation && p.orientation && typeof p.orientation === 'object') orientation = p.orientation; // The readable lines, which is what production_notes is: a text column the // order ticket and the print instructions print. Built from the SAME // helper the checkout summary and the quote PDF use where it is available, // so the floor reads the words the customer was shown rather than a second // rendering of them that could disagree. var notes = null; try { var lines = (typeof _ckPartNotes === 'function' ? _ckPartNotes(p) : []) || []; var joined = lines.map(function (l) { // _ckPartNotes escapes for HTML because its other two callers build // markup. This one is going into a database column and onto paper. return String(l == null ? '' : l) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'"); }).join('\n').trim().slice(0, 2000); notes = joined || null; } catch (e) { notes = null; } // And the readable lines, which _ckPartNotes rebuilds from the same live // state the three above depend on. if (!notes && typeof p.notes === 'string' && p.notes.trim()) notes = p.notes.trim().slice(0, 2000); return { notes: notes, annotations: pins.length ? pins : null, cosmetic: cosmetic, orientation: orientation, }; } const parts = _ckAppliedParts().map(function (p) { const v = appliedView(p) || p; return { name: p.name, fileName: p.name, partId: p.id, uploadId: p.uploadId || null, tech: v.tech, material: v.material, color: v.color, qty: v.qty, finish: v.finish, quality: v.quality, resolution: v.resolution, // AND THE PANTONE THEY TYPED, not just the fact that they typed one. // // Christina, 2026-09-06: "for custom folors, can you put the custom // input text as the color instead of 'custom color'? this makes it more // helpful." The Custom swatch writes the customer's Pantone / hex / // description onto the part (colorCustom, from #colorCustomInput) and it // never left the browser -- no payload carried it, so the floor row said // "custom" and the match the customer asked for was gone by checkout. // // The color KEY is untouched. lib/finishing.mjs isCustomColor() matches // on exactly 'custom' and is what routes a custom-colour cart to a quote // request instead of self-serve checkout, so the text rides BESIDE the // key rather than replacing it. lib/production-order.mjs is where the // floor row learns to print the text instead of the word. colorCustom: _ckColorCustom(v.colorCustom != null ? v.colorCustom : p.colorCustom), infillDensity: v.infillDensity, infillMult: v.infillMult, // Without this the server re-prices a cart with no finishing in it and // undercharges by exactly the finishing figure the customer just approved. finishing: v.finishing || p.finishing || null, bboxRaw: p.bboxRaw, scaleVec: p.scaleVec, displayUnit: p.displayUnit, surfaceCm2: p.surfaceCm2, bodyCount: p.bodyCount, shellCount: p.shellCount, // WHETHER THAT COUNT WAS TAKEN, not only its number: null when it was, // otherwise why not ('counting', or the reason it failed). The server // refuses to charge an SLA line that says it was not counted // (lib/piece-count-gate.mjs). typeof-guarded: harnesses lift this function. piecesUncounted: (typeof _piecesNotCounted === 'function') ? _piecesNotCounted(p) : null, volumeCm3: p.volumeCm3, volume_cm3: p.volume_cm3, volume: p.volume, // notes / annotations / cosmetic / orientation -- see _ckPartExtras. ..._ckPartExtras(p) }; }); const _lq = (typeof window !== 'undefined') && window.__lockedQuote; return { lockedQuote: _lq ? { no: _lq.no, token: _lq.token } : undefined, useCredit: _ckCreditOn(), useShipCredit: _ckShipCreditOn(), parts: parts, tier: orderDeliveryTier, dispatch: orderDispatch, shipRate: (ckSelectedRate && ckSelectedRate.rate) || 0, shipRateId: (ckSelectedRate && ckSelectedRate.id) || null, shipShipmentId: (typeof _ckShipmentId !== 'undefined' && _ckShipmentId) || null, shipLegs: (ckSelectedRate && ckSelectedRate.legs) || null, planBoxes: (typeof _ckPlanBoxes !== 'undefined' && _ckPlanBoxes) || null, shipOwnLabel: !!(ckSelectedRate && ckSelectedRate._ownLabel), discountCode: (ckDiscount && ckDiscount.code) || null, deliveryInstructions: (_ckShip && _ckShip.note) || null, // WHAT THIS PAGE IS SHOWING, so the server can refuse to book anything else. // typeof-guarded for the same reason _ckShipmentId above is: this function is // lifted into a vm by several harnesses, where a free identifier throws. shownCents: (typeof _ckShownGrand !== 'undefined' && _ckShownGrand != null) ? Math.round(Number(_ckShownGrand) * 100) : null, pageBuild: (typeof _CK_PAGE_BUILD !== 'undefined' && typeof _CK_PAGE_BUILD === 'string') ? _CK_PAGE_BUILD : null }; } function _wirePayChoice(grand) { _ckPaypalInited = false; _ckPaypalGrand = grand; const cardChoice = document.getElementById('payCardChoice'); const cardSec = document.getElementById('ckCardSection'); // A REFUSED ATTEMPT MUST STAY RETRYABLE. // // cardInited latched to true BEFORE initCardPayment ran, so the moment the // first attempt was refused every later click became a silent no-op: the // customer presses "Pay by card" and nothing happens at all, no card box and // no reason. That is what "nothing appears" was. // // Latch on the only thing that means it worked -- Stripe's iframe actually // being on screen -- rather than on having tried. cardBusy stops a double // click opening two payment intents while the first is still in flight. let cardBusy = false; const cardMounted = function () { return !!document.querySelector('#ckCardElement iframe'); }; if (cardChoice) cardChoice.addEventListener('click', async function () { _ckSetPayMethod('card'); cardChoice.classList.add('active'); if (cardSec) cardSec.style.display = ''; if (cardBusy || cardMounted()) return; cardBusy = true; try { // Re-ask before we ask the server for money, not after it has refused. var _amt = await _ckRefreshQuoteIfStale(grand); await initCardPayment(_amt); } finally { cardBusy = false; } }); } // Stripe.js is ~200KB and was fetched AFTER the payment-intent round trip, so // the two waits were stacked on the one click where the customer is watching a // blank box. Start it as soon as the checkout is on screen; by the time the PI // comes back it is usually already there. let _stripeJsReady = null; // ANSWERS WHETHER IT LOADED, rather than resolving either way. // // The .catch here swallowed the failure and resolved with undefined, so // `await _stripeJsReady` succeeded on a script that was never there and the // next line called an undefined global. Clearing _stripeJsReady on failure is // what lets the next press try again -- and it only means anything now that // _loadScript removes the dead tag instead of resolving off it. function _warmStripeJs() { if (!_stripeJsReady) { _stripeJsReady = _loadScript('https://js.stripe.com/v3/', 'stripe-js') .then(function () { return true; }) .catch(function () { _stripeJsReady = null; return false; }); } return _stripeJsReady; } async function initCardPayment(amount) { _warmStripeJs(); const msg = document.getElementById('ckCardMsg'); const btn = document.getElementById('ckPayCardBtn'); const cardWrap = document.getElementById('ckCardWrap'); try { const email = (document.getElementById('payEmail') || {}).value || ''; // THE PACK THE RATE WAS BUILT ON, sent with the payment too. Same idiom as // the ship-rates call; without it the server packs the cart a SECOND time // and the two answers are free to differ, which is what refused three // payments on 2026-09-15. _ckPrepackFor returns {} when the cart has moved // on, so a stale plan can never be offered. const r = await fetch('/api/payment-intent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign(_payCartPayload(), _orderCtxPayload(), _ckPrepackFor(_cartPartsForPacking()), { email: email })) }); if (r.status === 503) { if (cardWrap) cardWrap.innerHTML = '
    Card payments are being finalized — use PayPal below for now.
    '; return; } const j = await r.json(); if (!r.ok || !j.clientSecret) { if (j && j.detail === 'discount_invalid') { removeDiscount(); if (msg) msg.textContent = 'That discount code no longer applies to this cart, so it was removed. Please retry.'; return; } if (j && j.detail === 'ship_rate_invalid') { // Recorded: this was a customer-visible checkout failure that left no // trace in quoter_events for 30 days while people were hitting it. try { qEvent('ship_rate_invalid', { metadata: { rateId: (ckSelectedRate && ckSelectedRate.id) || null, shipmentId: _ckShipmentId || null } }); } catch (e) {} ckSelectedRate = null; // It was never expiry (see _ckRepointSelectedRate), and "one step" back // is Billing & Shipping, which has no UPS options on it. if (msg) msg.textContent = 'This order needs a UPS shipping option for this address. Go back to Lead Time & Delivery, pick one, then pay.'; if (typeof _updatePayGate === 'function') _updatePayGate(); return; } // A REFUSAL IS NOT A CARD PROBLEM, and must not be dressed as one. // // payment-intent and paypal-order both refuse a quote that was issued to // someone else (quote_not_yours), expired, or superseded. Until now card // said "Could not start card payment. Try PayPal below." -- which sends // the customer to the one place that is guaranteed to fail for exactly the // same reason, and is why this arrives as "paypal and stripe are not // working" rather than "this quote is not mine". The wording already // existed; only terms and bank transfer were showing it. if (_ckRefusalIsPermanent(j, r.status)) { if (msg) msg.textContent = _ckOrderRefusalText(j, r.status); return; } if (msg) msg.textContent = 'Could not start card payment. Try PayPal below.'; return; } // The card must never be asked for a different number than the screen shows. // // Order 11519, 2026-08-10: checkout displayed no sales tax -- correctly, // the customer holds an exemption certificate -- while payment-intent // priced the same cart WITH tax and created the PaymentIntent for $1,042.34 // more. The browser took the clientSecret and mounted the card without ever // looking at the amount, so the customer paid a total never shown to them. // // The server already returned the number. Nothing compared it. // // Reconstructed as a TOTAL rather than comparing the card amount directly, // so store credit cannot mask a difference: chargeCents is what the card is // asked for, creditApplied is the rest of the same total. var _ownCents = Math.round(Number(amount || 0) * 100); // A total they already confirmed when the server asked stands in for the // screen's own -- see _ckShownCentsFor. var _rk = (typeof _ckReask !== 'undefined' && _ckReask && typeof _ckReask.shown === 'function') ? _ckReask : null; var _shownCents = _rk ? _rk.shown('card', _ownCents) : _ownCents; var _serverCents = Math.round(Number(j.chargeCents || 0)) + Math.round(Number(j.creditApplied || 0) * 100); // A cent of rounding either way is not a disagreement. if (_shownCents > 0 && Math.abs(_serverCents - _shownCents) > 1) { try { window.mlqReportError && window.mlqReportError( new Error('checkout total mismatch: screen ' + _shownCents + ' cents vs server ' + _serverCents + ' cents'), { component: 'checkout' }); } catch (e) {} // AND ON THE SESSION, because one reporter has already proved not to be // enough. This refusal reported ONLY through mlqReportError, which was // never defined -- so from 2026-08-10 until today it fired in silence and // a cart priced wrong looked exactly like a customer who wandered off. // The card-init catch beside it survived that outage because it writes to // both places, and quoter_events is the half that kept working. // // It also lands where the rest of the session is: beside checkout_details // and checkout_gate_closed, so the refusal can be read in the order the // customer hit it rather than in a separate table. try { qEvent('checkout_total_mismatch', { metadata: { shownCents: _shownCents, serverCents: _serverCents, chargeCents: Number(j.chargeCents || 0), creditApplied: Number(j.creditApplied || 0) } }); } catch (e) {} // ASK, DON'T SEND THEM AWAY. "Please refresh the page" was the only way // forward, and a refresh redraws the figure the server just disagreed // with. The server's figure is drawn with a button; its click asks for a // fresh payment intent, which is compared again -- now against the // figure they said yes to -- and only then is the card form mounted. // Nothing is mounted, and nothing can be charged, before that click. // // The card box is no longer emptied first. ckCardMsg lives INSIDE it, so // emptying it detached the element this sentence was written into and // the customer saw a blank box with no reason at all. if (_rk && msg && _rk.ask({ method: 'card', wasCents: _shownCents, nowCents: _serverCents, host: msg, confirmLabel: 'Continue with $' + formatPrice(_serverCents / 100), onConfirm: function () { initCardPayment(amount); } })) return; if (msg) msg.textContent = (window.MLQ_REASK && window.MLQ_REASK.changedText) ? window.MLQ_REASK.changedText(_shownCents, _serverCents) : 'The total changed while you were checking out, and nothing has been charged. Email hello@makelab.com and we will finish this order for you.'; // Refusing to mount is the point: charging a figure nobody said yes to // is worse than stopping. return; } // Awaited by its RETURN VALUE, not by re-reading the variable: on a failure // _warmStripeJs clears _stripeJsReady, and `await null` resolves happily. const _stripeOk = await _warmStripeJs(); if (!_stripeOk || typeof Stripe === 'undefined') { throw new Error('stripe.js did not load'); } window._stripePiId = (j.clientSecret || '').split('_secret')[0]; _stripe = Stripe(j.publishableKey); // The options live in public/stripe-checkout.js so this page and the accept // page cannot drift into two different payment experiences -- which is // exactly what they had done (Christina, 2026-09-12: "I want the whole // thing to be exactly the same"). The saved-cards fallback moved in there // with them, unchanged. // Saved-cards (Customer Session) is best-effort — if it isn't enabled on the account it would // break the element, so fall back to a plain card element so checkout always works. // Why saved cards are or aren't on this checkout, in the console. It used to // be impossible to tell the difference between "no saved cards" and "the // session failed" from the browser. _stripeElements = window.MLQ_STRIPE.mountPaymentElement({ stripe: _stripe, clientSecret: j.clientSecret, customerSessionClientSecret: j.customerSessionClientSecret, savedCardsError: j.savedCardsError, selector: '#ckCardElement', hostEl: document.getElementById('ckCardElement'), }); if (btn) { btn.disabled = false; // The figure they confirmed, on the button that charges it. The label // was drawn from the page's own total, which the server just corrected. if (_shownCents !== _ownCents) { btn.textContent = 'Place order \u00b7 $' + formatPrice(Number(j.chargeCents || 0) / 100); // The question is answered; its box (and its spent button) goes. if (msg) msg.textContent = ''; } btn.onclick = async () => { btn.disabled = true; if (msg) msg.textContent = 'Processing…'; const res = await _stripe.confirmPayment({ elements: _stripeElements, confirmParams: { return_url: location.origin + location.pathname }, redirect: 'if_required' }); if (res.error) { if (msg) msg.textContent = res.error.message || 'Payment failed.'; btn.disabled = false; return; } // What the card was charged, for _ckPaidNotRecorded. try { window._ckPaidCents = Math.round(Number(j.chargeCents || 0)) || null; } catch (e) {} placeOrderSuccess('card'); }; } } catch (e) { // WHY IT FAILED, OR WE WILL BE HERE AGAIN. // // Daniela Beraun (Skolnick Architecture) opened this card form nineteen // times on 2026-09-08 and never got a card into it: every PaymentIntent is // still requires_payment_method and not one carries a last_payment_error, // so nothing was ever submitted and she was never charged. She reported // "the pay with card button does not work" -- and there was nothing to // look at. This catch wrote a sentence on her screen and swallowed the // exception: no console error, no app_errors row, no way to tell whether // Stripe.js, the Elements session or the mount was what threw. // // The customer still gets a sentence. We now also get the reason, in the // two places we actually read: app_errors carries the user's email, and // quoter_events puts it on their session beside checkout_gate_closed. try { window.mlqReportError && window.mlqReportError( e instanceof Error ? e : new Error('card init failed: ' + String(e)), { component: 'checkout-card' }); } catch (e2) {} var _noStripe = typeof Stripe === 'undefined'; try { qEvent('card_init_failed', { metadata: { why: String((e && e.message) || e).slice(0, 200), stripeLoaded: !_noStripe } }); } catch (e3) {} // A CARD PROBLEM IS NOT ALWAYS A CARD PROBLEM. // // If Stripe.js never loaded then `Stripe` is undefined and THAT is the // throw -- an ad blocker or a corporate network filter, not the card. In // that case "try PayPal below" is the one instruction that cannot help, // which is exactly the trap the refusal branch above was corrected for on // 2026-09-06: sending someone to the door that fails for the same reason // is how this arrives as "paypal and stripe are both broken". if (msg) msg.textContent = _noStripe ? 'We could not load our card form — an ad blocker or a network filter is the usual cause. Try again, or use another payment option below.' : 'Card payment unavailable right now. Try PayPal below.'; } } // The PayPal client id, fetched once and started EARLY. // // initPayPal cannot build the SDK URL until this returns, because the client id is a // query parameter in it — so this was a strictly serial leg on the critical path, and // nothing about it needed to wait for the button to mount. prefetchPaypalConfig() is // called when checkout opens; by the time the button mounts the answer is usually in // hand. It is a promise, not a value, so a second caller joins the first request // instead of issuing another. let _paypalCfgPromise = null; function prefetchPaypalConfig() { if (_paypalCfgPromise) return _paypalCfgPromise; if (['localhost','127.0.0.1','[::1]','0.0.0.0'].indexOf(location.hostname) >= 0) return null; _paypalCfgPromise = fetch('/api/paypal-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) .then(function (r) { return r.ok ? r.json() : null; }) // A failed prefetch must not be sticky: clear it so the real mount can retry and // show the customer a proper message rather than silently reusing a null. .catch(function () { _paypalCfgPromise = null; return null; }); return _paypalCfgPromise; } async function initPayPal(amount) { // THE BUTTON, ITS ORDER, ITS CAPTURE AND ITS REFUSALS LIVE IN ONE FILE. // // public/checkout-paypal.js — the same file the design checkout (accept.html) // mounts. What used to be here was the only copy, so when a second checkout // needed PayPal the choice was to duplicate the total-mismatch refusal or go // without it; both are how a money guard stops being true in one place // (Christina, 2026-09-12: "it's one of those things that should be the same // as the other checkout areas"). // // What stays here is what only this page can say: which refusals it can // explain, and the telemetry it files. const msg = document.getElementById('ckPaypalMsg'); await window.MLQ_PAYPAL.mountPayPalButtons({ selector: '#ckPaypalButtons', hostEl: document.getElementById('ckPaypalButtons'), msgEl: msg, // The FULL total, store credit included: the server is asked for the // remainder and reports the credit separately. amount: amount, stubClass: 'ck-pay-opt', // Usually already resolved — prefetched when checkout opened, because the // SDK URL cannot be built without the client id. config: () => prefetchPaypalConfig() || Promise.resolve(null), payload: () => Object.assign(_payCartPayload(), _ckPrepackFor(_cartPartsForPacking()), { poNumber: (typeof _ckPoValue === 'function') ? _ckPoValue() : null }), // The same question the card asks, drawn under the PayPal button. reask: (typeof _ckReask !== 'undefined' && _ckReask) || null, onRefusal: (j, status) => { if (j && j.detail === 'discount_invalid') { removeDiscount(); if (msg) msg.textContent = 'That discount code no longer applies to this cart, so it was removed. Please retry.'; } // Same refusals reach here, and this threw the reason away: the button // just failed with nothing on screen. else if (_ckRefusalIsPermanent(j, status) && msg) { msg.textContent = _ckOrderRefusalText(j, status); } }, onMismatch: (shownCents, serverCents, j) => { try { window.mlqReportError && window.mlqReportError( new Error('paypal total mismatch: screen ' + shownCents + ' cents vs server ' + serverCents + ' cents'), { component: 'checkout-paypal' }); } catch (e) {} try { qEvent('checkout_total_mismatch', { metadata: { via: 'paypal', shownCents: shownCents, serverCents: serverCents, chargeCents: Number(j.chargeCents || 0), creditApplied: Number(j.creditApplied || 0) } }); } catch (e) {} }, onPaid: (method, ppOrderId, paidCents) => { window._paypalOrderId = ppOrderId; window._ckPaidCents = paidCents || null; placeOrderSuccess('paypal'); }, }); } function _qsid() { try { var k = 'quoter_sid'; var v = localStorage.getItem(k); if (!v) { v = 's-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36); localStorage.setItem(k, v); } return v; } catch (e) { return 's-anon'; } } // Persist the uploaded model file to storage (fire-and-forget, fail-open). Large files // go straight to a signed URL, so there's no serverless body-size limit. // `cad` is {urn, sig} for a native CAD upload, and null for everything else. // The server VERIFIES the signature before storing it -- an unsigned urn is a // caller naming any object in the APS bucket and having us render from it. function _cadOf(f) { return (f && f._cadUrn) ? { urn: f._cadUrn, sig: f._cadUrnSig } : null; } // Uploads still in flight, so a save can wait for them. Measured 2026-08-18: // 62 of 679 quoted parts carried no uploadId. On the customer side the cause // is a race -- Save (or checkout) before the upload, or a split body's own // export (flushSplitBodyFiles), had landed. Split bodies get their OWN files; // they never carry the assembly's id (tests/split-body-carries-file.test.mjs). window.__persistPending = window.__persistPending || {}; function _trackPersist(fileName, promise) { var key = String(fileName || '') + '#' + Math.random().toString(36).slice(2, 7); var pr = Promise.resolve(promise).catch(function () { return null; }).finally(function () { delete window.__persistPending[key]; // The gate closed while this was in flight; it has to be told the wait is // over. Without this the customer sits on "we are still receiving your // files" until they touch a form field, because every other caller of // _updatePayGate is a keystroke or a render. try { if (typeof _updatePayGate === 'function') _updatePayGate(); } catch (e) {} }); window.__persistPending[key] = pr; return pr; } // Wait for every in-flight upload -- ordinary persists and the split-body // exports -- but never forever: a save that hangs is worse than a save with one // link short (the server name-matches what it can). async function _awaitPersists(ms) { var list = Object.keys(window.__persistPending || {}).map(function (k) { return window.__persistPending[k]; }); try { if (typeof flushSplitBodyFiles === 'function') list.push(flushSplitBodyFiles()); } catch (e) {} if (!list.length) return true; var timeout = new Promise(function (r) { setTimeout(function () { r('timeout'); }, ms || 8000); }); var done = await Promise.race([Promise.all(list.map(function (x) { return Promise.resolve(x).catch(function () { return null; }); })).then(function () { return 'done'; }), timeout]); return done === 'done'; } // AN UPLOAD THAT DID NOT FINISH, TOLD TO THE CUSTOMER. // // Until this, a failed upload was visible only in telemetry. The customer // watched a part appear on screen, priced and rotatable from the browser's own // copy of the mesh, with no way to know its file never reached us -- and the // first anyone found out was an order with nothing to print. // // Only TERMINAL failures land here: the sign call, and the PUT after all three // attempts. The transient put_retry deliberately does not -- a retry that then // succeeds is not something to alarm anybody about. // // It names the files, because "an upload failed" with three parts on screen // tells the customer nothing they can act on. It offers no retry button: the // bytes are out of scope by now (a 675MB buffer is not held on the chance it is // wanted again), so the only honest instruction is to add the file once more. window.__uploadsLost = window.__uploadsLost || []; // WHICH SIGN REFUSALS ARE WORTH ASKING AGAIN ABOUT, and how long to wait. // // A named function rather than a condition buried in the loop, because the // POLICY is the thing worth guarding: retrying a bad_file only delays the // truth, and not retrying a 429 turns a "not yet" into "we won't have them to // print" -- 269 of 276 upload failures in 30 days were exactly that. // // Returns the wait in ms, or null when asking again cannot help. function _signRetryWaitMs(status, body, attempt) { var transient = !status || status === 429 || status === 408 || status >= 500; if (!transient) return null; var s = Number((body && body.retryAfterSec) || 0); var ms = (Number.isFinite(s) && s > 0) ? s * 1000 : attempt * 2000; return Math.min(ms, 30000); } function _uploadLost(fileName) { try { var name = String(fileName || 'Your file'); if (window.__uploadsLost.indexOf(name) === -1) window.__uploadsLost.push(name); var many = window.__uploadsLost.length > 1; var el = document.getElementById('ulLost'); if (!el) { el = document.createElement('div'); el.id = 'ulLost'; el.className = 'ul-lost'; el.setAttribute('role', 'alert'); document.body.appendChild(el); } el.innerHTML = '' + '
    Upload didn’t finish
    ' + '
    We couldn’t receive ' + (many ? 'these files' : 'this file') + ':
    ' + '' // A file its network refused by both routes cannot be added again from // where they sit -- the instruction Huhtamaki could never follow. Emailing // works for every file on the list, so it wins when any of them was. + '
    ' + (_anyUploadBlocked(window.__uploadsLost) ? _escHtml(blockedAdvice(many)) : 'You can keep working, but we won’t have ' + (many ? 'them' : 'it') + ' to print. Please remove ' + (many ? 'those parts' : 'that part') + ' and add ' + (many ? 'them' : 'it') + ' again.') + '
    '; var x = document.getElementById('ulLostX'); if (x) x.addEventListener('click', function () { window.__uploadsLost = []; try { el.remove(); } catch (e2) { el.style.display = 'none'; } }); } catch (e) {} } // A FILE THEIR NETWORK REFUSED BY BOTH ROUTES, SAID ON THE PART ITSELF. // // Huhtamaki (2026-09-23 and 09-29) and Fresh: every upload refused by a // corporate network, and the first they heard of it was the payment gate // telling them to add the files again -- which could never work from where // they sat. The second route (public/upload-put.mjs) now gets those through. // What it cannot is a network that refuses our own origin as well, and for // that there is one instruction that works -- email the file -- so it is said // // * on the part's own row, for as long as the part is there without a file: // not in a toast, and not only in a notice that can be dismissed // * in the notice every lost file already gets // * at the payment gate, instead of "add it again" // // Only on evidence. uploadPut calls a failure 'blocked' when the direct PUT // was refused AND the forward got an HTTP answer from us, or our own site // answered while the forward did not. An offline laptop is 'offline' and // gets the notice it always had. // // The team is told once per tab, and told only what this page drew: the // alert (lib/upload-blocked-alert.mjs) quotes the part's message only when // the row carrying it is on the screen. window.__uploadsBlocked = window.__uploadsBlocked || []; function _anyUploadBlocked(names) { try { var blocked = window.__uploadsBlocked || []; return (names || []).some(function (n) { return blocked.indexOf(n) !== -1; }); } catch (e) { return false; } } // Named by the file the part came from -- the name _persistUpload was given. // A part holding an upload id holds a file, whatever happened to an earlier // copy of it, so it is never marked. function _partUploadBlocked(p) { if (!p || p.uploadId) return false; var blocked = window.__uploadsBlocked || []; return blocked.indexOf(String(p.sourceFile || p.name || '')) !== -1 || blocked.indexOf(String(p.name || '')) !== -1; } function _uploadBlocked(fileName, detail) { var name = String(fileName || 'Your file'); try { if (window.__uploadsBlocked.indexOf(name) === -1) window.__uploadsBlocked.push(name); } catch (e) {} // The part's own row, redrawn so it carries the message. try { if (typeof renderPartsList === 'function') renderPartsList(); } catch (e) {} // The notice, with the instruction that works. _uploadLost(name); // The team, once per tab, told what is actually on the screen. try { if (!uploadPut.firstInTab('upload_blocked')) return; var onPart = false, notice = false; try { onPart = !!document.querySelector('#partsList .part-row-upload-blocked'); } catch (e) {} try { notice = !!document.getElementById('ulLost'); } catch (e) {} qEvent('upload_blocked', { modelName: name, metadata: { partMarked: onPart, noticeShown: notice, detail: String(detail == null ? '' : detail).slice(0, 200) } }); } catch (e) {} } async function _persistUpload(fileName, buffer, thumb, cad) { if (window.__lqPreload || window.__lockedQuote) return; // viewing a locked quote never writes storage // Failures stay non-blocking but MUST be visible in telemetry — a paid order // whose print-source file never uploaded is an ops incident, not a no-op. var _fail = function (stage, detail) { try { qEvent('persist_failed', { modelName: fileName, metadata: { stage: stage, detail: String(detail == null ? '' : detail).slice(0, 300) } }); } catch (e) {} }; try { if (!buffer) return; // A RATE LIMIT IS A "NOT YET", NOT A LOST FILE. // // The sign call had no retry at all, so a 429 became "we won't have them to // print". 269 of the 276 upload failures in 30 days were exactly that, and // they cluster on the carts that matter most -- one model split into 31 // parts spends 31 of the allowance by itself, and an office shares one IP. // The PUT below has retried transient failures for months; this never did. // // Only the transient ones: a 429, a 408, a 5xx or a dead network. A refusal // that will not become true by asking again -- bad_file, too big -- still // fails on the first answer, because retrying those only delays the truth. var r = null, j = null; for (var _s = 1; _s <= 3; _s++) { try { r = await fetch('/api/quoter-upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: fileName, sessionId: _qsid(), thumb: thumb || null, urn: (cad && cad.urn) || null, urnSig: (cad && cad.sig) || null }) }); } catch (e) { r = null; } j = null; if (r) { try { j = await r.json(); } catch (e) {} } if (j && j.ok && j.signedUrl) break; // Honour Retry-After when the server sent one, so a burst spreads out // instead of three attempts landing inside the same closed window. var _hdr = (r && r.headers && r.headers.get && Number(r.headers.get('Retry-After'))) || 0; var _wait = _signRetryWaitMs(r && r.status, (j && j.retryAfterSec) ? j : (_hdr ? { retryAfterSec: _hdr } : null), _s); if (_wait === null || _s === 3) break; _fail('sign_retry', ((r && r.status) || 'network') + ' attempt ' + _s + ', waiting ' + Math.round(_wait / 1000) + 's'); await new Promise(function (r2) { setTimeout(r2, _wait); }); } if (!j || !j.ok || !j.signedUrl) { _fail('sign', (j && (j.reason || j.error)) || (r ? r.status : 'network')); _uploadLost(fileName); return; } // ONE SHOT WAS THE WHOLE PROBLEM. // // This was a single unretried, untimed, unabortable fetch of the entire // file. Measured on real uploads: 0-1MB takes about 0.8s, but 50-200MB has // a median of 26s and a maximum of 373s -- one customer's successful // 675.7MB PUT ran 342s at 2.0MB/s. Any interruption anywhere in a window // that long became a permanent silent orphan, because the row was already // written server-side before the browser was handed this url. // // Three attempts against the SAME signed url. That matters: minting a new // url mints a new path, which leaves a second orphan row instead of // repairing the first. Re-PUTting the same url only works because the sign // now asks for upsert (lib/upload-store.mjs) -- without it the retry 409s. // The url is good for 2 hours, so even a 6-minute upload has room for three. // // The deadline scales with the file: no fixed timeout can suit both a 40KB // bracket and a 675MB scan. 2 minutes plus a minute per 10MB, at a floor of // 2 and a ceiling of 10, covers every upload in the last 30 days with room // and still gives up on something genuinely wedged. var _mb = (buffer && buffer.byteLength ? buffer.byteLength : 0) / 1048576; var _deadlineMs = Math.min(10 * 60000, Math.max(2 * 60000, (2 + _mb / 10) * 60000)); var put = null, _lastErr = null, _route = null; for (var _try = 1; _try <= 3; _try++) { var _ac = (typeof AbortController !== 'undefined') ? new AbortController() : null; var _timer = _ac ? setTimeout(function () { try { _ac.abort(); } catch (e) {} }, _deadlineMs) : null; try { // The same PUT, with a second route through our own origin when the // network refuses our storage host (public/upload-put.mjs). An HTTP // status and our own deadline come back exactly as fetch gave them, // so every rule in this loop still reads them the same way. put = await uploadPut(j.signedUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'x-upsert': 'true' }, body: buffer, signal: _ac ? _ac.signal : undefined, }, { name: fileName }); _route = uploadPut.verdictOf(put); } catch (e) { put = null; _lastErr = (e && e.name === 'AbortError') ? ('timeout after ' + Math.round(_deadlineMs / 1000) + 's') : String((e && e.message) || e); _route = uploadPut.verdictOf(e); } finally { if (_timer) clearTimeout(_timer); } if (put && put.ok) break; // A 4xx that is not 408 will not become true by asking again. if (put && put.status >= 400 && put.status < 500 && put.status !== 408 && put.status !== 429) break; if (_try < 3) { _fail('put_retry', (put ? put.status : _lastErr) + (_route ? ' (' + _route + ')' : '') + ' attempt ' + _try); await new Promise(function (r2) { setTimeout(r2, _try * 1500); }); } } if (!put || !put.ok) { var _why = (put ? put.status : (_lastErr || 'no response')) + (_route ? ' (' + _route + ')' : ''); _fail('put', _why); // Three attempts are spent, and what the customer is told depends on // why: a network that refused both routes gets the one instruction that // works for it, and anything else -- offline included -- the notice it // always had. if (_route === 'blocked') _uploadBlocked(fileName, _why); else _uploadLost(fileName); return null; } // The bytes are in storage now, so ask the server to measure them. That // measurement is what gets priced; the browser's is a preview that put a // number on screen while this was happening. if (j.uploadId) { // MEASURED, OR ASKED AGAIN. The measure route answers busy -- and writes // nothing -- when other measurements already hold its instance's memory // (api/geometry.py, _MEASURING); asked again a little later, it measures. // Two more tries, then the row is left unmeasured, and priced from the // browser's figure like any upload the server has not measured yet. var _measure = function (tries) { return fetch('/api/geometry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId: j.uploadId }), }).then(function (r) { var read = (r && typeof r.json === 'function') ? r.json().catch(function () { return null; }) : Promise.resolve(null); return read.then(function (a) { if (!a || a.reason !== 'busy' || tries >= 3) return; var wait = Math.min(60, Number(a.retry_after) || 15) * 1000; return new Promise(function (ok) { setTimeout(ok, wait); }).then(function () { return _measure(tries + 1); }); }); }); }; _measure(1) // ASK FOR OUR OWN PICTURE, once the bytes are measured. // // Step 1 of retiring the browser renderers: the server draws the // persisted thumbnail, this tab only shows a preview. The measure route // above is Python and cannot kick a render server-side, so the tab does // it -- through the render route's owner door, which draws only rows // this session owns. Chained AFTER measure so a picture is never made // from bytes the server has not yet accepted, and never awaited: it is // not on the path between the customer and anything they see. .then(function () { return fetch('/api/render/thumbnail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId: j.uploadId }), }); }) .catch(function (e) { _fail('measure', e && e.message); }); } return j.uploadId || null; } catch (e) { _fail('network', e && e.message); } return null; } // Re-quote from the portal: download a saved model and load it into the quoter. // Exposed as a global so portal.js (a separate module) can call it. // Reapply a previous order's configuration once its files have loaded. // // handleFiles() is fire-and-forget and geometry lands asynchronously, so the // parts do not exist yet when it returns. Poll briefly for them by filename // rather than guessing a delay -- a big STEP takes far longer than a small STL, // and a fixed timeout would restore config on the quick ones only. async function _mlqApplyOrderConfig(cart) { var want = (cart && cart.parts) || []; if (!want.length) return; var byName = {}; want.forEach(function (w) { if (w && w.fileName) byName[String(w.fileName).toLowerCase()] = w; }); for (var tries = 0; tries < 40; tries++) { var ready = (typeof parts !== 'undefined' && parts.length >= want.length); if (ready) break; await new Promise(function (r) { setTimeout(r, 250); }); } if (typeof parts === 'undefined' || !parts.length) return; var touched = []; parts.forEach(function (p) { var w = byName[String(p.name || p.fileName || '').toLowerCase()]; if (!w) return; // Only assign what actually resolved. A null means the pricing model no // longer knows that material, and the configurator's own default is a // better answer than an unpriceable value. if (w.tech) p.tech = w.tech; if (w.material) p.material = w.material; if (w.color) p.color = w.color; if (w.resolution) p.resolution = w.resolution; if (w.infillDensity != null) p.infillDensity = w.infillDensity; if (w.qty) p.qty = w.qty; // EVERYTHING ELSE THE CUSTOMER ALREADY DECIDED. // // api/order-reorder.mjs CARRIED sends seventeen fields and calls them "the // configuration worth carrying into a new quote -- everything here is a // decision the client already made". This loop applied six of them, so // pressing Reorder on a part scaled to 2x with vapour smoothing gave back // an unscaled part with no finishing: a different object, at a different // price, and the missing finishing also stops cartRequiresRfq firing so it // checks out self-serve instead of becoming a quote request. if (w.quality && !w.resolution) p.resolution = w.quality; if (w.infillMult != null) p.infillMult = w.infillMult; if (w.finishing) p.finishing = w.finishing; if (w.notes || w.productionNotes) p.notes = w.notes || w.productionNotes; if (Array.isArray(w.annotations) && w.annotations.length) p.annotations = w.annotations; if (w.cosmetic) p.cosmetic = w.cosmetic; if (w.orientation) p.orientation = w.orientation; // THE SCALE, BUT NOT THE FILE UNIT. The scale is the customer's own // decision and is reapplied. displayUnit is deliberately NOT: a reorder // re-uploads the file and askUnits asks the customer what unit it is in // before this runs, and overwriting the answer they just gave with a stored // one would be the app arguing with them. For the same file they answer the // same way, which is when the two agree anyway. var _rsv = w.scaleVec; if (_rsv && typeof _rsv === 'object') { var _ra = Array.isArray(_rsv); var _rx = Number(_ra ? _rsv[0] : _rsv.x), _ry = Number(_ra ? _rsv[1] : _rsv.y), _rz = Number(_ra ? _rsv[2] : _rsv.z); if (isFinite(_rx) && isFinite(_ry) && isFinite(_rz)) { p.scaleVec = { x: _rx, y: _ry, z: _rz }; try { if (typeof applyPartScale === 'function') applyPartScale(p); } catch (e) {} } } touched.push(p); }); if (!touched.length) return; try { if (typeof updatePartPrice === 'function') updatePartPrice(touched); } catch (e) {} try { if (typeof renderPartsList === 'function') renderPartsList(); } catch (e) {} try { if (typeof renderSelectedPart === 'function') renderSelectedPart(); } catch (e) {} try { if (typeof refreshBuildEnvelopes === 'function') refreshBuildEnvelopes(); } catch (e) {} } window.mlqReQuote = async function (items, cart) { try { items = Array.isArray(items) ? items : [items]; var files = [], lost = [], verdict = null; for (var i = 0; i < items.length; i++) { if (!items[i] || !items[i].url) continue; // THE SAME SECOND ROUTE AN UPLOAD HAS (public/upload-put.mjs). A network // that refuses our storage host refuses its downloads too: Huhtamaki, // 2026-09-30 -- the reorder of 11322 pulled all four models back out of // Box on the server, then this page fetched them straight from storage, // was refused, and added nothing, with no word to anyone. var _nm = items[i].name || 'model'; var r = null; try { r = await uploadPut(items[i].url, { method: 'GET' }, { name: _nm, kind: 'download' }); } catch (e) { verdict = verdict || uploadPut.verdictOf(e); lost.push(_nm); continue; } if (!r || !r.ok) { verdict = verdict || uploadPut.verdictOf(r); lost.push(_nm); continue; } var blob = await r.blob(); files.push(new File([blob], _nm, { type: 'application/octet-stream' })); } if (lost.length) { try { qEvent('reorder_load_failed', { metadata: { lost: lost.slice(0, 20), of: items.length, verdict: verdict } }); } catch (e) {} // Refused on both routes: the one instruction that works from there. if (verdict === 'blocked') { try { showQuoterToast(blockedAdvice(lost.length > 1), 12000); } catch (e) {} } } if (files.length && typeof handleFiles === 'function') { handleFiles(files); // Not awaited into the return value: the caller only needs to know the // files loaded, and config restore is best-effort on top. if (cart) _mlqApplyOrderConfig(cart); return true; } } catch (e) {} return false; }; // ARRIVING FROM THE SLIM ACCOUNT PAGE. // // /account serves account.html, which leaves out the quoter boot on purpose, so // re-quote there cannot call mlqReQuote directly. It stores the model ids and // sends the browser here; this picks them up exactly once. // // Waits for load because it needs handleFiles, and clears the handover BEFORE // using it so a failed load cannot re-fire on every future visit. window.addEventListener('load', function () { var raw = null; try { raw = sessionStorage.getItem('mlq_requote'); if (raw) sessionStorage.removeItem('mlq_requote'); } catch (e) { return; } if (!raw) return; var ids = []; try { ids = JSON.parse(raw) || []; } catch (e) { return; } if (!Array.isArray(ids) || !ids.length) return; (async function () { var items = []; for (var i = 0; i < ids.length; i++) { try { var r = await fetch('/api/model-download', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: ids[i] }), }); var j = await r.json().catch(function () { return {}; }); if (j && j.url) items.push({ url: j.url, name: j.name || 'model' }); } catch (e) { /* one model failing must not lose the rest */ } } if (items.length) { try { await window.mlqReQuote(items); } catch (e) {} } })(); }); // Live view-unit control from the portal preferences (immediate, no reload). window.mlqSetViewUnit = function (u) { try { if (['mm', 'cm', 'in'].indexOf(u) < 0) return; viewUnit = u; try { document.querySelectorAll('.env-unit-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.unit === u); }); } catch (e) {} try { if (typeof renderPartsList === 'function') renderPartsList(); } catch (e) {} try { if (typeof renderSelectedPart === 'function') renderSelectedPart(); } catch (e) {} } catch (e) {} }; (async function () { try { var j = await window.mlqAccount.get(); if (!j) return; window._quoterPrefs = (j.profile && j.profile.preferences) || {}; var vu = window._quoterPrefs.viewer_units; if (vu && vu !== viewUnit) window.mlqSetViewUnit(vu); applyLogisticsDefaults(); } catch (e) {} })(); // Pre-select the customer's saved lead time and dispatch. Only ever fills a gap: // once they have picked either one themselves, their choice stands. The tier has // to still be offered — a saved Next Day means nothing on a part that cannot make it. function applyLogisticsDefaults(opts) { var pr = window._quoterPrefs; if (!pr) return; try { if (pr.default_dispatch && typeof ckDispatchChosen !== 'undefined' && !ckDispatchChosen) { // Courier cannot take a pallet, so a remembered courier preference must // not survive onto a freight quote -- it would select a row that is not // on screen and leave the step looking answered. orderDispatch = (pr.default_dispatch === 'courier' && _lqFreight()) ? 'pickup' : pr.default_dispatch; ckDispatchChosen = true; } if (pr.default_lead && typeof ckLeadChosen !== 'undefined' && !ckLeadChosen) { var offered = (typeof availableTiers === 'function') ? availableTiers() : []; if (!offered.length || offered.some(function (t) { return t.key === pr.default_lead; })) { orderDeliveryTier = pr.default_lead; ckLeadChosen = true; } } if (opts && opts.silent) return; if (typeof renderLeadRows === 'function') renderLeadRows(); if (typeof renderDeliveryRows === 'function') renderDeliveryRows(); if (typeof updateAccDone === 'function') updateAccDone(); // The summary is what the customer reads at checkout; without this it kept // the "Not selected" it was painted with before the preferences arrived. if (typeof renderCheckoutSummary === 'function') renderCheckoutSummary(); } catch (e) {} } // Apply the customer's saved quoter defaults to a freshly-added part (pre-selects // their usual config so they don't re-pick each time). Values map 1:1 to the // quoter: tech=fdm/sla/isla/mjf/fgf, material=model key, quality=resolution key. // Deliberately does nothing to a part's configuration. // // Preferences used to carry default technology / material / quality / infill. // Those were removed (they duplicated a per-part choice, under labels that did // not match the configurator) — but this kept reading the values still stored on // the profile, so an account that had once saved Industrial SLA pre-selected it // on every upload with no control left to clear it. // // Left as a no-op rather than deleted so the stored keys stay harmless without a // migration, and so callers do not need to change. function applyQuoterDefaults(_p) { /* logistics-only preferences: nothing to apply per part */ } // After a redirect payment (Cash App / Amazon Pay), Stripe returns here with // ?payment_intent=&redirect_status=. Capture the order server-side, show confirmation. async function _handlePaymentReturn() { try { var q = new URLSearchParams(location.search); var pi = q.get('payment_intent'); var rs = q.get('redirect_status'); if (!pi || rs !== 'succeeded') return; window._paymentReturn = true; // claim the screen so the router won't wipe the result try { history.replaceState({}, '', location.pathname); } catch (e) {} var r = await fetch('/api/finalize-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentIntentId: pi }) }); var j = await r.json(); // Pass the ORDER NUMBER only. _showReturnConfirmation filters it too, but a // payment key should not travel toward a customer-facing renderer at all. if (j && j.ok) { // The order is placed, so the PO it carried is spent -- see _ckPoForgetAll. try { if (typeof _ckPoForgetAll === 'function') _ckPoForgetAll(); } catch (e) {} _showReturnConfirmation(j.order_id || null, j.total); } else { // Money was captured but the order didn't record cleanly — be honest, and log // it so it surfaces in reconciliation instead of looking like a happy order. try { qEvent('order_finalize_failed', { metadata: { paymentIntent: pi, reason: (j && j.reason) || 'unknown' } }); } catch (e) {} _showReturnPending(); } } catch (e) {} } function _showReturnConfirmation(orderRef, total) { var scr = document.getElementById('checkoutScreen'); if (scr) scr.classList.add('visible'); var body = document.querySelector('#checkoutScreen .ck-frame'); if (!body) return; var _esc = function (v) { return (v == null ? '' : String(v)).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }; // Only an ORDER NUMBER is shown, and it is labelled as one. // // This took `j.order_id || j.order_ref`, so a payment that returned before // the production order existed printed the ML- payment key under the heading // "Reference" — an id the customer cannot look up, quote to us, or use to // find the order in their account. With no number the row is simply left out // and the email carries the id instead (Christina, 2026-08-12: "all client // facing surfaces are important"). var _rawRef = String(orderRef == null ? '' : orderRef); var ref = /^[0-9]+$/.test(_rawRef) ? _esc(_rawRef) : ''; var tot = (total != null) ? ('$' + Number(total).toFixed(2)) : ''; var refRow = ref ? ('
    Order' + ref + '
    ') : ''; var totRow = tot ? ('
    Total' + tot + '
    ') : ''; _ckShowConfirm(body, '
    \u2713
    ' + '

    Payment received

    ' + '

    Thanks \u2014 your order is confirmed and a confirmation is on its way to your email.

    ' + '
    ' + refRow + totRow + '
    ' + '
    '); try { _ckMarkCartSpent(); } catch (e) {} var sb = document.getElementById('retStartAnother'); if (sb) sb.addEventListener('click', function () { window.location.href = '/'; }); } function _showReturnPending() { var scr = document.getElementById('checkoutScreen'); if (scr) scr.classList.add('visible'); var body = document.querySelector('#checkoutScreen .ck-frame'); if (!body) return; _ckShowConfirm(body, '
    \u2713
    ' + '

    Payment received

    ' + '

    We’re finalizing your order now and will email your receipt shortly. If you don’t hear from us within an hour, call +1-888-355-1570 and we’ll sort it out right away.

    ' + '
    '); var sb = document.getElementById('retStartPending'); if (sb) sb.addEventListener('click', function () { window.location.href = '/'; }); } function qEvent(eventType, extra) { try { var body = Object.assign({ sessionId: _qsid(), eventType: eventType, email: window._quoterEmail || '' }, extra || {}); var s = JSON.stringify(body); if (navigator.sendBeacon) { navigator.sendBeacon('/api/quoter-event', new Blob([s], { type: 'application/json' })); } else { fetch('/api/quoter-event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: s, keepalive: true }).catch(function () {}); } } catch (e) {} } // Emit a per-part `quote` event (with the computed price) once a part has tech+material // and a ready price. Deduped per config so async-priced files (STEP/3dm) log when ready. function _ckCustName() { var s = _ckShip || _ckBill || {}; return s.name || ''; } function _ckCustCompany() { var s = _ckShip || _ckBill || {}; return s.company || ''; } // Emit checkout state PER APPLIED PART so each part reflects its own checkout episode. // Session-level events would smear name/company/dispatch/tier across every checkout in the // same browser session (one quoter_sid), rewriting past checkouts when a new one is made. function _logCheckoutSnapshot() { try { var applied = (typeof _ckAppliedParts === 'function') ? _ckAppliedParts() : []; var nm = _ckCustName(), co = _ckCustCompany(); var disp = (typeof orderDispatch !== 'undefined') ? orderDispatch : null; var tier = (typeof orderDeliveryTier !== 'undefined') ? orderDeliveryTier : null; applied.forEach(function (p) { qEvent('checkout_details', { modelName: p.name, sourceFile: p.sourceFile || null, metadata: { dispatch: disp, tier: tier, customerName: nm, company: co } }); }); } catch (e) {} } function _logQuote(p) { try { if (!p || !p.name || !p.tech || !p.material) return; var pr = (typeof calcPartPrice === 'function') ? calcPartPrice(p) : null; if (!pr || pr.unset || pr.total == null) return; var sig = p.tech + '|' + p.material + '|' + p.qty + '|' + pr.total; if (p._lastQuoteSig === sig) return; p._lastQuoteSig = sig; qEvent('quote', { modelName: p.name, tech: p.tech, material: p.material, quantity: p.qty, price: pr.total, metadata: { sourceFile: p.sourceFile || null } }); } catch (e) {} } // Everything finalize-order needs to build a COMPLETE order if the customer // never returns from a redirect payment (Cash App / Amazon Pay): addresses, // dates, company, ccEmails. Stashed with the PaymentIntent at creation time. // The arrival this order was sold on: the solver's answer if one was worked // out, otherwise the ready date plus the carrier's OWN quoted transit, and only // then the ready date alone -- which means "we do not know", not "it arrives // the day it is made". function _ckArrivalFor(ship, ready) { if (ship && ship._arrival) return ship._arrival; var days = ship && Number(ship.deliveryDays); if (ready && Number.isFinite(days) && days > 0) return addBusinessDays(ready, days); return ready; } // THE TRANSIT ITSELF, NOT JUST THE DAY IT LANDS ON. // // Christina, 2026-09-06: "also the shipping estimation in yellow is still off, // but i thought we fixed that. i chose a nyc address and it told me 1 biz day // in the quoter. so it should be reflected here" -- the floor's calendar drew a // three-day transit band on an order UPS had quoted at one day across the city. // // deliveryDate above says WHEN it lands. amfg_parts_orders.transit_days // (migration 161) says HOW LONG it takes, and that is the column the // production calendar actually reads. lib/production-order.mjs has been ready // to write it and api/place-order.mjs to read it all along -- but the browser // never sent the number, so it went in null and src/lib/carrierTransit.js fell // back to guessing from the service NAME: every Ground three days, whether it // is crossing Brooklyn or the country. // // rate.deliveryDays is what the carrier said when they quoted THIS shipment, // and it is the same figure the option was labelled with ("~1 biz day"). // Clamped here to what the column will take (CHECK 1..21) so a carrier oddity, // a stale rate, or the freight-review placeholder -- which quotes no transit at // all -- sends null rather than a figure that would fail the whole order // insert. A pickup sends null too: the server nulls it regardless, and a // transit on an order nobody ships would be a lie wherever it was written. function _ckTransitDays(ship, dispatch) { if (dispatch === 'pickup') return null; var n = Number(ship && ship.deliveryDays); return Number.isInteger(n) && n >= 1 && n <= 21 ? n : null; } function _orderCtxPayload() { var tier = (typeof _ckSelectedTier === 'function') ? _ckSelectedTier() : null; var prodStart = (typeof productionStart === 'function') ? productionStart() : null; var ready = (tier && typeof ckLeadChosen !== 'undefined' && ckLeadChosen) ? addBusinessDays(prodStart, tier.businessDays) : null; var iso = function (d) { try { return d ? _isoLocal(d) : null; } catch (e) { return null; } }; var ship = (typeof ckSelectedRate !== 'undefined' && ckSelectedRate) || null; return { company: ((document.getElementById('payCompany') || {}).value || ''), // The order contact: who placed it and how to reach them. Collected and // validated in step 3 but never transmitted, so the receipt greeted people by // their BILLING name and nothing was ever saved back to the profile. contactName: ((document.getElementById('payName') || {}).value || ''), contactPhone: ((document.getElementById('payPhone') || {}).value || ''), // Their purchase order number -- the ONE place every order body gets it: // the payment intent's stash and _persistOrder (which merges this first) // both read it from here. ALWAYS the field, null when the box is empty: // place-order reads a body with no poNumber at all as a page from before // the PO box existed. typeof-guarded: several harnesses lift this function // into a vm where the page's helpers do not exist. poNumber: (typeof _ckPoValue === 'function') ? _ckPoValue() : null, dispatch: orderDispatch, // A packer-freight placeholder posts the stamp, not its row label; the // server writes the same words from its own verdict regardless. shippingService: ship ? (ship._shippingService || ship._label || ship.service || null) : null, readyDate: iso(ready), // THE CARRIER'S OWN TRANSIT, NOT THE READY DATE WEARING ITS NAME. // // Christina, 2026-09-02: "we chose next day air saver but why is it not // arriving until 9/10?" The production app had to ESTIMATE, because the // arrival was almost never stored -- 25 of 165 shipped orders -- and where // it was stored it was often the READY date, because _arrival is only set // by the need-by-date solver and a normal rate pick falls straight past it // to `ready`. So the one screen that knew the true figure threw it away. // // rate.deliveryDays is what UPS said when they quoted this shipment. Used // in preference to both, so the order carries the arrival the customer was // shown rather than one the floor works out again later. deliveryDate: iso(_ckArrivalFor(ship, ready)), // And how LONG it takes, which is a different question the floor asks // separately -- see _ckTransitDays. Without this the calendar guesses. deliveryDays: _ckTransitDays(ship, orderDispatch), shippingAddress: orderDispatch === 'pickup' ? null : (_ckShip || null), billingAddress: (ckBillingSameAsShip && orderDispatch !== 'pickup') ? (_ckShip || null) : (_ckBill || null), ccEmails: (typeof ckCcEmails !== 'undefined' && Array.isArray(ckCcEmails)) ? ckCcEmails : [], resumedQuoteNo: (typeof window !== 'undefined' && window.__resumedQuoteNo) ? String(window.__resumedQuoteNo).slice(0, 40) : null }; } function _persistOrder(method, orderId) { if (method === 'test') return; var tier = (typeof _ckSelectedTier === 'function') ? _ckSelectedTier() : null; var prodStart = (typeof productionStart === 'function') ? productionStart() : null; var ready = (tier && typeof ckLeadChosen !== 'undefined' && ckLeadChosen) ? addBusinessDays(prodStart, tier.businessDays) : null; var iso = function (d) { try { return d ? _isoLocal(d) : null; } catch (e) { return null; } }; var ship = (typeof ckSelectedRate !== 'undefined' && ckSelectedRate) || null; // _orderCtxPayload FIRST, so the named fields below still win. // // It was built for /api/payment-intent and never merged in here, so // resumedQuoteNo -- whose whole purpose is that api/place-order.mjs:1042 // reads it to CC the person who saved and handed over the cart -- reached the // charge and not the order. The owner of a shared cart heard nothing about // the order placed from it. var payload = Object.assign(_payCartPayload(), _orderCtxPayload(), { method: method, // _payCartPayload's shownCents is whatever method was selected when the // step was drawn; this is the one being placed. See _ckShownCentsFor. shownCents: _ckShownCentsFor(method), orderId: orderId, sessionId: _qsid(), company: (window._orderForm && window._orderForm.payCompany) || ((document.getElementById('payCompany') || {}).value || ''), contactName: (window._orderForm && window._orderForm.payName) || ((document.getElementById('payName') || {}).value || ''), contactPhone: (window._orderForm && window._orderForm.payPhone) || ((document.getElementById('payPhone') || {}).value || ''), email: (window._orderForm && window._orderForm.payEmail) || window._quoterEmail || '', stripePaymentIntentId: window._stripePiId || null, paypalOrderId: window._paypalOrderId || null, dispatch: orderDispatch, // A packer-freight placeholder posts the stamp, not its row label; the // server writes the same words from its own verdict regardless. shippingService: ship ? (ship._shippingService || ship._label || ship.service || null) : null, readyDate: iso(ready), // THE CARRIER'S OWN TRANSIT, NOT THE READY DATE WEARING ITS NAME. // // Christina, 2026-09-02: "we chose next day air saver but why is it not // arriving until 9/10?" The production app had to ESTIMATE, because the // arrival was almost never stored -- 25 of 165 shipped orders -- and where // it was stored it was often the READY date, because _arrival is only set // by the need-by-date solver and a normal rate pick falls straight past it // to `ready`. So the one screen that knew the true figure threw it away. // // rate.deliveryDays is what UPS said when they quoted this shipment. Used // in preference to both, so the order carries the arrival the customer was // shown rather than one the floor works out again later. deliveryDate: iso(_ckArrivalFor(ship, ready)), // And how LONG it takes, which is a different question the floor asks // separately -- see _ckTransitDays. Without this the calendar guesses. deliveryDays: _ckTransitDays(ship, orderDispatch), shippingAddress: orderDispatch === 'pickup' ? null : (_ckShip || null), billingAddress: (ckBillingSameAsShip && orderDispatch !== 'pickup') ? (_ckShip || null) : (_ckBill || null), ccEmails: (typeof ckCcEmails !== 'undefined' && Array.isArray(ckCcEmails)) ? ckCcEmails : [] }); return fetch('/api/place-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(function () { return { ok: false }; }); } // All reasons this cart must route to an RFQ (sales handoff) rather than instant checkout. // A part that can't fit the build envelope in ANY orientation needs a human (RFQ). function _partOverBuild(p) { if (!p || !p.bboxRaw) return false; const v = appliedView(p) || p; const rule = (typeof TECH_BUILD_VOL !== 'undefined') ? TECH_BUILD_VOL[v.tech] : null; if (!rule) return false; if (typeof envelopeFitState === 'function' && v.tech === p.tech) { // Anything that exceeds the build volume and cannot be printed in one piece // goes to a quote — including the case where we could not work out whether // it fits. An unverified part is not a printable part. const _st = envelopeFitState(p); return _st === 'toobig' || _st === 'unknown'; } const u = p.displayUnit || 'mm'; const k = u === 'in' ? 25.4 : u === 'cm' ? 10 : 1; const sv = p.scaleVec || { x: 1, y: 1, z: 1 }; const dims = [Math.abs(p.bboxRaw.x * sv.x * k), Math.abs(p.bboxRaw.y * sv.y * k), Math.abs(p.bboxRaw.z * sv.z * k)].sort(function (a, b) { return a - b; }); const rd = [rule.x, rule.y, rule.z].sort(function (a, b) { return a - b; }); return dims[0] > rd[0] || dims[1] > rd[1] || dims[2] > rd[2]; } function cartRfqReasons() { const reasons = []; // A quote staff issued is already priced by hand -- that IS the quote request, // answered. Sending the client who received it into an RFQ asks them to request // the thing they are holding. The three checkout endpoints already exempt a // locked quote; this is the front end that never got the memo and routed them // to the RFQ step before any endpoint was consulted (Christina 2026-08-04). if (window.__lockedQuote) return reasons; if (typeof tierNeedsReview === 'function' && typeof ckLeadChosen !== 'undefined' && ckLeadChosen && tierNeedsReview(orderDeliveryTier)) reasons.push('Expedited lead time'); const flagged = _ckAppliedParts().filter(function (p) { return p && p.manualReview; }); const _rfqNames = function (list) { return list.map(function (p) { return p.name; }).slice(0, 3).join(', ') + (list.length > 3 ? '…' : ''); }; const _interlock = flagged.filter(function (p) { return p.reviewReason === 'interlocking'; }); const _otherFlag = flagged.filter(function (p) { return p.reviewReason !== 'interlocking'; }); if (_interlock.length) reasons.push('Interlocking assembly (parts are linked but don\u2019t touch, so we confirm printability first): ' + _rfqNames(_interlock)); if (_otherFlag.length) reasons.push('Manual review: ' + _rfqNames(_otherFlag)); // Finishing a vendor or a person quotes by hand sends the whole cart to a quote // request. A service marked selfServe in the pricing admin is priced outright // and does not — mirrors cartRequiresRfq in lib/finishing.mjs. const _cat = (typeof _finCatalog === 'function') ? _finCatalog() : []; const _needsQuote = function (p) { var f = p && p.finishing; var list = Array.isArray(f) ? f : (f ? [f] : []); return list.some(function (svc) { var key = typeof svc === 'string' ? svc : (svc && (svc.key || svc.service)); if (!key) return false; var hit = _cat.find(function (x) { return x && x.key === key; }); return !(hit && hit.selfServe === true); }); }; const _fin = _ckAppliedParts().filter(_needsQuote); if (_fin.length) { var _names = _fin.map(function (p) { return p.name; }).slice(0, 3).join(', ') + (_fin.length > 3 ? '\u2026' : ''); reasons.push('Finishing requested: ' + _names); } // A custom colour is a Pantone somebody sources and mixes; same reasoning. const _cc = _ckAppliedParts().filter(function (p) { var v = (typeof appliedView === 'function' && appliedView(p)) || p; return v && String(v.color || '').trim().toLowerCase() === 'custom'; }); if (_cc.length) { reasons.push('Custom colour match: ' + _cc.map(function (p) { return p.name; }).slice(0, 3).join(', ') + (_cc.length > 3 ? '\u2026' : '')); } // A PART WHOSE PIECES COULD NOT BE COUNTED IS NEVER CHARGED AS ONE PIECE // (Christina, 2026-10-01). SLA prices each separate piece, so without a count // there is no honest price to take; a person prices it instead. Only where // the count changes the price -- an FDM part's pieces cost nothing extra. const _uncounted = _ckAppliedParts().filter(function (p) { const v = (typeof appliedView === 'function' && appliedView(p)) || p; return typeof _piecesUncounted === 'function' && _piecesUncounted(p) && _shellsPriced(v); }); if (_uncounted.length) reasons.push('Separate pieces not counted: ' + _rfqNames(_uncounted)); const over = _ckAppliedParts().filter(function (p) { return _partOverBuild(p); }); if (over.length) reasons.push('Exceeds build volume: ' + over.map(function (p) { return p.name; }).slice(0, 3).join(', ') + (over.length > 3 ? '…' : '')); return reasons; } function cartNeedsRfq() { return cartRfqReasons().length > 0; } function _rfqTilde() { return (typeof cartNeedsRfq === 'function' && cartNeedsRfq()) ? '~' : ''; } function _ckDateFmt(d) { return _rfqTilde() + formatDate(d); } // RFQ carts: ~date = to be confirmed // A date on a selectable row, as a pill rather than more of the sentence. function _ckRowDate(d) { return '' + _ckDateFmt(d) + ''; } let _rfqRefFiles = []; function _rfqAddFiles(list) { Array.prototype.slice.call(list || []).forEach(function (f) { if (!_rfqRefFiles.some(function (g) { return g.name === f.name && g.size === f.size; })) _rfqRefFiles.push(f); }); _rfqRenderFileList(); } function _rfqRenderFileList() { const host = document.getElementById('rfqFileList'); if (!host) return; host.innerHTML = _rfqRefFiles.map(function (f, i) { var sz = f.size > 1048576 ? (f.size / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(f.size / 1024)) + ' KB'; var over = f.size > RFQ_MAX_FILE ? ' · too large' : ''; var nm = String(f.name).replace(/[<>&"]/g, ''); // A drawing, a photo of a broken part and a spec sheet all arrive as // "attachment 3" otherwise, and whoever quotes it has to guess which is // which. One line each, optional. var note = String(f._note || '').replace(/[<>&"]/g, ''); return '
    📎' + nm + '' + sz + over + '
    ' + ''; }).join(''); Array.prototype.forEach.call(host.querySelectorAll('.rfq-fl-x'), function (b) { b.addEventListener('click', function () { _rfqRefFiles.splice(+b.dataset.i, 1); _rfqRenderFileList(); }); }); Array.prototype.forEach.call(host.querySelectorAll('.rfq-fl-note'), function (inp) { inp.addEventListener('input', function () { var t = _rfqRefFiles[+inp.dataset.i]; if (t) t._note = inp.value; }); }); } function _wireRfqDrop() { const drop = document.getElementById('rfqDrop'); const input = document.getElementById('rfqFiles'); if (!drop || !input) return; drop.addEventListener('click', function () { input.click(); }); input.addEventListener('change', function () { _rfqAddFiles(input.files); input.value = ''; }); ['dragenter', 'dragover'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('drag'); }); }); drop.addEventListener('dragleave', function (e) { e.preventDefault(); if (!drop.contains(e.relatedTarget)) drop.classList.remove('drag'); }); drop.addEventListener('drop', function (e) { e.preventDefault(); drop.classList.remove('drag'); if (e.dataTransfer && e.dataTransfer.files) _rfqAddFiles(e.dataTransfer.files); }); } function goToRfqStep() { const s2 = document.getElementById('ckStep2'), s3 = document.getElementById('ckStep3'); if (!s2 || !s3) return; try { qEvent('rfq_opened', { metadata: { reasons: cartRfqReasons() } }); } catch (e) {} s2.style.display = 'none'; s3.style.display = ''; _ckStep = 3; _ckStep3Mode = 'rfq'; setCrumbs(3); const back = document.getElementById('checkoutBack'); if (back) back.innerHTML = '← Back to Lead Time & Delivery'; renderRfqStep(); try { var _cksc = document.getElementById('checkoutScreen'); if (_cksc) _cksc.scrollTop = 0; window.scrollTo(0, 0); } catch (e) {} } const RFQ_BUDGETS = ['Under $500', '$500 – $1,000', '$1,000 – $2,500', '$2,500 – $5,000', '$5,000 – $10,000', '$10,000 – $25,000', '$25,000 – $50,000', '$50,000+']; function renderRfqStep() { const wrap = document.getElementById('ckStep3'); if (!wrap) return; _rfqRefFiles = []; const applied = _ckAppliedParts(); const tier = _ckSelectedTier(); const ready = ckLeadChosen ? addBusinessDays(productionStart(), tier.businessDays) : null; // The SAME split the checkout summary and the paid receipt read, not a third // derivation of the same money. This block used to call // computeOrderMinimums(applied, orderDeliveryTier) -- the CHOSEN tier -- so its // headline figure carried the expedite premium while the part rows printed // directly above it come from _ckSummaryRows at the BASELINE tier. The rows // never summed to the number under them, and the "Lead time +$X" row beneath // restated a premium that was already inside it. const _split = _ckTotalsSplit(applied, orderDeliveryTier, ckLeadChosen); const mins = _split.base; const _leadDelta = _split.leadDelta; const _fin = Number((mins.finishing && mins.finishing.total) || 0) || 0; const _del = _ckDeliveryLine(); const shipCost = _ckShipCost(); // Tender order copied from the checkout summary deliberately: parts + lead, // then shipping, then the discount, then tax on all of it. No invoicing fee: // no payment method has been chosen yet, and a figure nobody recorded gets no // row and no place in the total. const _orderTotal = mins.grandTotal + _leadDelta; const _disc = _ckDiscountAmount(_orderTotal); const _pretax = Math.round((_orderTotal + shipCost - _disc) * 100) / 100; const _tax = _ckTaxOn(_pretax); const grand = _pretax + _tax; // Everything the Est. total is made of gets a line the customer can add up. // A recorded zero is a fact and is stated; something simply not there gets no // row at all rather than a confident "+$0.00". const _minRow = mins.adjustmentTotal > 0 ? '
    Material minimum adjustment+$' + formatPrice(mins.adjustmentTotal) + '
    ' : ''; const _finRow = _fin > 0 ? '
    Finishing+$' + formatPrice(_fin) + '
    ' : ''; const _discRow = _disc > 0 ? '
    Discount−$' + formatPrice(_disc) + '
    ' : ''; const esc = v => (v == null ? '' : String(v)).replace(/"/g, '"'); const reasons = cartRfqReasons(); const reasonHtml = (reasons.length ? reasons : ['This configuration needs a quick human check.']).map(function (r) { var ic = r.indexOf('Interlocking') === 0 ? '🔗' : (r.indexOf('Manual review') === 0 ? '🔧' : (r.indexOf('Expedited') === 0 ? '⚡' : '•')); return '
    ' + ic + '' + esc(r) + '
    '; }).join(''); const budgetOpts = RFQ_BUDGETS.map(function (b) { return ''; }).join(''); const sumRows = _ckSummaryRows(applied); wrap.innerHTML = `

    Request a quote

    A couple things about your order need a human touch, so we'll confirm a tailored quote — usually within 4 business hours. Add anything that helps us get it right.

    Why we're quoting this by hand
    ${reasonHtml}
    👤 Your details
    📋 About your order
    📎 Files
    ${/* The uploaded models were listed here AND in the order summary beside it, so the same filenames appeared twice on one screen and this card read as though it were asking for them again. The summary is where the parts live; this card is only for the extra material. */ ''}
    ⤓
    Drag & drop files here, or browse
    Up to 3 MB each
    Order summary
    ${sumRows}
    Subtotal${_rfqTilde()}$${formatPrice(mins.subtotal)}
    ${_minRow} ${_finRow}
    Lead time · ${ckLeadChosen ? esc(tier.name) + (ready ? ' — ready ' + _ckDateFmt(ready) : '') : 'not selected'}${ckLeadChosen ? _ckLeadAmtText(_leadDelta) : '—'}
    ${esc(_del.label)} · ${esc(_del.detail)}${esc(_del.val)}
    ${_discRow}
    ${_ckTaxLabel()}${_rfqTilde()}$${formatPrice(_tax)}
    Est. total${_rfqTilde()}$${formatPrice(grand)}

    Estimate only — final pricing and dates (~) confirmed in your quote.

    `; // renderRfqStep paints after _ckPrefillContact has already run, so its copies // of the same two fields have to be locked on the way in. try { var _ae = window._quoterEmail || ''; if (_ae) _ckLockField('rfqEmail', _ae, 'This is the account you are signed in with.'); var _an = (window._quoterPrefs && window._quoterPrefs.full_name) || ''; if (_an) _ckLockField('rfqName', _an, 'From your account — change it in your profile.'); } catch (e) {} if (typeof renderCheckoutTimeline === 'function') renderCheckoutTimeline(); if (typeof renderLeadCalendar === 'function') renderLeadCalendar(); _wireRfqDrop(); const btn = document.getElementById('rfqSubmitBtn'); if (btn) btn.addEventListener('click', submitRfq); } // Reference files go STRAIGHT to storage via a signed URL, so the 4.5 MB Vercel // function body limit no longer decides how big a STEP file may be. The old // base64-through-the-function path stays as a fallback for small files, so a // transient signing failure still gets the attachment through. const RFQ_MAX_FILE = 50 * 1024 * 1024; const RFQ_INLINE_MAX = 3 * 1024 * 1024; async function _rfqPostJson(body) { const resp = await fetch('/api/rfq-upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const j = await resp.json().catch(function () { return {}; }); return { ok: resp.ok, body: j }; } async function _rfqUploadInline(f) { const dataBase64 = await new Promise(function (resolve, reject) { const r = new FileReader(); r.onload = function () { resolve(String(r.result).split(',')[1] || ''); }; r.onerror = reject; r.readAsDataURL(f); }); const r = await _rfqPostJson({ name: f.name, type: f.type, dataBase64: dataBase64 }); return (r.ok && r.body.url) ? r.body.url : null; } async function _rfqUploadDirect(f) { const signed = await _rfqPostJson({ mode: 'sign', name: f.name, size: f.size }); if (!signed.ok || !signed.body.uploadUrl) return null; // Supabase Storage today, not our R2 bucket: uploadPut sends it exactly as fetch did. const put = await uploadPut(signed.body.uploadUrl, { method: 'PUT', headers: { 'Content-Type': signed.body.contentType }, body: f }, { name: f.name }); if (!put.ok) return null; // The server only publishes it after checking what actually landed. const done = await _rfqPostJson({ mode: 'confirm', path: signed.body.path }); return (done.ok && done.body.url) ? done.body.url : null; } async function _uploadRefFiles() { const out = []; const files = _rfqRefFiles; for (const f of files) { const meta = { name: f.name, size: f.size, type: f.type, note: String(f._note || '').trim() || null }; if (f.size > RFQ_MAX_FILE) { out.push(Object.assign(meta, { error: 'too_large' })); continue; } try { let url = await _rfqUploadDirect(f); if (!url && f.size <= RFQ_INLINE_MAX) url = await _rfqUploadInline(f); if (url) out.push(Object.assign(meta, { url: url })); else out.push(Object.assign(meta, { error: 'upload_failed' })); } catch (e) { out.push(meta); } } return out; } async function submitRfq() { const _v = function (id) { const el = document.getElementById(id); return el ? (el.value || '').trim() : ''; }; const email = _v('rfqEmail') || window._quoterEmail || ''; const nParts = _ckAppliedParts().length; const total = '$' + formatPrice(_ckGrandTotalNum()); const tier = _ckSelectedTier(); const btn = document.getElementById('rfqSubmitBtn'); const msg = document.getElementById('rfqMsg'); // A quote request ends in a human calling the customer back - the same // phone rule as checkout, checked before anything uploads. if (!_phoneOk(_v('rfqPhone'))) { if (msg) msg.textContent = _phoneDigits(_v('rfqPhone')).length ? 'That does not look like a full phone number.' : 'Add a phone number so our team can reach you about this quote.'; const _pf = document.getElementById('rfqPhone'); if (_pf) _pf.focus(); return; } if (msg) msg.textContent = ''; var _btnLabel = btn ? btn.textContent : 'Send request'; if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; } if (msg && _rfqRefFiles.length) msg.textContent = 'Uploading files…'; const refFiles = await _uploadRefFiles(); // A REQUEST WE CANNOT PRINT FROM IS NOT A REQUEST. Christina, 2026-09-23. // // Q-15985-1 came through here: three STEP files, every PUT refused by // cxie@fresh.com's corporate proxy, an RFQ recorded anyway naming files that // do not exist. The quote reached staff with nothing to open and the // customer was told we had it. The same question the pay and save doors ask. // // AFTER _awaitPersists on purpose -- an upload still in flight is a "not // yet", and the customer should not be refused for our slowness. try { await _awaitPersists(8000); } catch (e) {} var _fileWhy = (typeof _ckFileGateWhy === 'function') ? _ckFileGateWhy() : null; if (_fileWhy) { try { qEvent('rfq_blocked', { metadata: { why: String(_fileWhy).slice(0, 200) } }); } catch (e) {} if (msg) msg.textContent = _fileWhy; if (btn) { btn.disabled = false; btn.textContent = _btnLabel; } return; } const rfq = { name: _v('rfqName'), email: email, phone: _v('rfqPhone'), company: _v('rfqCompany'), project: _v('rfqProject'), inHandsBy: _v('rfqDate'), budget: _v('rfqBudget'), useCase: _v('rfqUse'), notes: _v('rfqNotes'), reasons: cartRfqReasons(), modelFiles: _ckAppliedParts().map(function (p) { return p.name; }), referenceFiles: refFiles }; try { qEvent('rfq_submitted', { metadata: { tier: orderDeliveryTier, tierName: tier && tier.name, reasons: rfq.reasons }, price: _ckGrandTotalNum() }); } catch (e) {} var _rfqSaved = false; var _rfqQuoteNo = null; var _rfqReason = null; var _rfqRefusal = null; try { var payload = Object.assign(_payCartPayload(), _orderCtxPayload(), { method: 'rfq', status: 'rfq_review', sessionId: _qsid(), email: email, dispatch: orderDispatch, rfq: rfq }); var _rr = await fetch('/api/place-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); _rfqSaved = !!(_rr && _rr.ok); // The server mints a real quote number for the request. Reading only .ok // threw it away, so the customer was told "received" with nothing to quote // back at us and no way to find the request again. if (_rfqSaved) { try { var _rj = await _rr.json(); _rfqQuoteNo = (_rj && _rj.quote_no) || null; _rfqReason = (_rj && _rj.rfq_reason) || null; } catch (e) { _rfqQuoteNo = null; } } // A BLOCKED ACCOUNT (2026-10-02) is told the server's own sentence. else if (_rr) { try { var _ej = await _rr.json(); if (_ej && _ej.error === 'customer_blocked') _rfqRefusal = _ej.detail || null; } catch (e) { _rfqRefusal = null; } } } catch (e) { _rfqSaved = false; } if (!_rfqSaved) { // No webhook backstop for RFQs — a silent failure loses the lead, so never // show "received" unless the server actually recorded it. if (msg) msg.textContent = _rfqRefusal || 'We couldn’t submit your request — please try again, or call us at +1-888-355-1570.'; if (btn) { btn.disabled = false; btn.textContent = _btnLabel; } return; } showOrderConfirmation({ name: rfq.name, email: email, parts: nParts, total: total, paid: false, method: 'rfq', rfq: true, quoteNo: _rfqQuoteNo, reason: _rfqReason }); } // What actually went wrong, in words the customer can act on. // // Every one of these was previously a toast saying "please try again" -- advice // that cannot work for any of them, because none are transient. The error code // is appended so that when someone reads the message back to us over the phone // we know exactly which branch refused, without needing their console. // The real reason can arrive in DETAIL rather than in error. // // resolveLockedQuote throws quote_superseded / quote_expired / quote_ordered, // and place-order catches anything it does not recognise into a generic // pricing_failed, carrying the true code in detail. Reading only error told a // customer on a revised quote "we could not price this order automatically", // which is both wrong and unactionable -- the fix is to open the current // version. This is the exact refusal that hit Kiosk on 2026-08-06. // WHAT TO DO ABOUT THE STATE /api/quote-accept ANSWERED WITH. // // Kept as a function rather than inline branches because the accept handler // cannot be run in a test -- it needs a live page -- and this routing is // exactly what broke: sign_in_required had no branch at all for a month. // // 'ordered' the quote already became an order; go to it. // 'sign_in' nobody is signed in. NOT an error, and the URL must survive it. // 'message' a genuine dead end; say so and clear the link. // DID THE VIEWER LOAD ANYTHING AT ALL? // // cxie@fresh.com, 2026-09-24: her office network blocks direct access to our // file storage, so every model download failed and the cart came up empty -- // then refused her files, because a locked quote does not take uploads. She // ended on an upload screen she could not use, with no way back to /accept, // which would have taken her card without needing a single model. // // NOTHING loaded, not "some". A partial load is still a usable cart and the // banner already says how many parts are not previewable; bailing there would // take a working checkout away from someone who has one. function _lqShouldBailToAccept(storedCount, loadedCount) { return Number(storedCount) > 0 && Number(loadedCount) === 0; } function _acceptNextStep(state) { var s = String(state || ''); if (s === 'ordered') return 'ordered'; if (s === 'sign_in_required') return 'sign_in'; return 'message'; } function _ckRefusalCode(jj) { var code = (jj && jj.error) || ''; var detail = String((jj && jj.detail) || ''); if (code === 'pricing_failed' && /^quote_/.test(detail)) return detail; return code; } // A refusal the customer cannot clear by repeating themselves. Status alone is // not enough: a superseded quote comes back 400, which reads as transient and // is not. function _ckRefusalIsPermanent(jj, status) { var code = _ckRefusalCode(jj); // A checkout reference another account's order already carries: this page // has let it go (_ckAttemptDone), so the next press is a new attempt and // goes through. A 409, and the one 409 that pressing again does clear. if (code === 'attempt_taken') return false; if (code.indexOf('quote_') === 0) return true; if (code === 'terms_not_available' || code === 'payment_method_not_allowed') return true; if (code === 'po_required') return true; return status === 403 || status === 409; } function _ckOrderRefusalText(jj, status) { var code = _ckRefusalCode(jj); // A BLOCKED ACCOUNT (2026-10-02): the server's own sentence, which says how to reach us. if (code === 'customer_blocked') return (jj && jj.detail) || "We can't take new quotes or orders on this account. Please contact us at hello@makelab.com or +1-888-355-1570."; if (code === 'quote_not_yours') { var who = (jj && jj.quoteEmail) ? ' (' + jj.quoteEmail + ')' : ''; return 'This quote was issued to a different person' + who + '. For security an order can only be placed by the address it was sent to \u2014 ask us to reissue it to you and it will go straight through.'; } // resolveLockedQuote failures all arrive as quote_. if (code === 'quote_superseded') return 'This quote has been revised since it was sent. Open the most recent version and the order will go through.'; if (code === 'quote_expired') return 'This quote has expired. Ask us to refresh it and it will go straight through.'; if (code.indexOf('quote_') === 0) return 'This quote is no longer available to order from. Ask us to reissue it \u2014 nothing is wrong with your account.'; // A card or PayPal refused BEFORE any money moved (payment-intent and // paypal-order say which): the words below are about invoicing, and would // tell an invoice-only account it cannot be invoiced. if (code === 'payment_method_not_allowed' && jj && (jj.method === 'card' || jj.method === 'paypal')) { return 'This account is not set up to ' + (jj.method === 'card' ? 'pay by card' : 'pay with PayPal') + '. Choose another way to pay, or contact us and we will set it up.'; } if (code === 'terms_not_available' || code === 'payment_method_not_allowed') { return 'Your account is not currently set up to order on invoice. Contact us and we will enable it \u2014 this is not something you can fix here.'; } if (code === 'po_required') return 'Your company requires a PO number on every order. Add it in the PO number box with your contact details, then place the order again.'; // lib/piece-count-gate.mjs: an SLA line the page could not count is never // charged as one piece. The page sends such a cart to a quote request before // any door is asked, so this is the server holding the same line. if (code === 'pieces_require_quote') return 'We could not count the separate pieces in one of your files, so this order needs a quote. Go back and request a quote, and we will price it by hand.'; if (code === 'pricing_failed') return 'We could not price this order automatically. Contact us and we will finish it for you.'; if (code === 'attempt_taken') return 'This order could not be placed as sent. Press the button again and it will go through.'; if (status === 401 || status === 403) return 'You are not able to place this particular order. Contact us and we will sort it out \u2014 it is not a problem with your card or your account balance.'; return 'We could not place the order. Please try again, and if it happens twice contact us' + (code ? ' and mention "' + code + '"' : '') + '.'; } // THE SERVER ASKED AGAIN, SO ASK THE CUSTOMER. // // place-order refuses a total the screen did not show and, since 2026-09-29, // answers with the figure it WOULD book (reask, byMethod). That figure is drawn // under the button that was pressed, with a button of its own; pressing it // places the order again, and _ckShownCentsFor then sends the figure they // said yes to. Nothing is posted until then. // // Only the methods that post BEFORE money moves. Card and PayPal are asked at // their own checks, before the card form mounts or PayPal is handed an order. function _ckReaskOffer(method, jj) { var doors = { terms: ['ckTermsPay', 'ckTermsPayMsg'], bank: ['ckBankPay', 'ckBankPayMsg'], credit: ['ckCreditPay', 'ckCreditPayMsg'], free: ['ckCreditPay', 'ckCreditPayMsg'], sim: ['ckSimPay', 'ckSimPayMsg'], }; var r = (typeof _ckReask !== 'undefined' && _ckReask && typeof _ckReask.ask === 'function') ? _ckReask : null; var R = (typeof window !== 'undefined' && window.MLQ_REASK) || null; var door = doors[method]; var q = (R && door) ? R.fromRefusal(method, jj) : null; if (!r || !q) return false; var host = document.getElementById(door[1]); if (!host) return false; return !!r.ask({ method: method, wasCents: q.wasCents, nowCents: q.nowCents, note: q.note, host: host, confirmLabel: 'Place order \u00b7 $' + formatPrice(q.nowCents / 100), onConfirm: function () { var b = document.getElementById(door[0]); if (b) { b.disabled = true; b.textContent = 'Placing order\u2026'; } placeOrderSuccess(method); }, }); } // ONE CHECKOUT ATTEMPT, ONE REFERENCE. // // An invoice, a bank transfer, store credit and a test order are booked by // place-order alone, and nothing about them tells a repeat apart the way a // card's payment id does. The reference sent with them (orderId) was minted // fresh on every press, so when the order was booked and its answer never // reached this page -- a 504 after the insert, a dropped connection -- the // customer was told to try again and the second press booked a second order. // // So one checkout attempt keeps one reference: this cart, sent again, goes // under the reference it was first sent with, and place-order answers a // reference it has already booked with that order (api/place-order.mjs, // ATTEMPT_KEYED_METHODS). Kept in sessionStorage, so reloading the page on the // same attempt keeps it too; let go the moment an order is confirmed, so the // next order -- even of this same cart -- is a new attempt. // // WHAT MAKES IT THE SAME ATTEMPT is what is being made and how: the quote, // each part's file, size, quantity and every option priced on it, the lead // time and the delivery. Not the way of paying: a customer who lost the answer // to "invoice me" and then pressed "bank transfer" still wants one order, and // the server hands back the one that was booked. Not the upload id either: a // reload that loses the cart has the files dropped in again, under new ids. // // Self-contained (hash, storage and fallback inside) because harnesses lift it // out of the page into a vm, where nothing around it exists. function _ckAttemptKey() { var c = {}; try { c = _payCartPayload() || {}; } catch (e) { c = {}; } var num = function (v, k) { var n = Number(v); return (v != null && isFinite(n)) ? Math.round(n * k) : null; }; var basis = ''; try { basis = JSON.stringify([ (c.lockedQuote && c.lockedQuote.no) || null, c.tier || null, c.dispatch || null, (c.parts || []).map(function (p) { var vol = p.volumeCm3 != null ? p.volumeCm3 : (p.volume_cm3 != null ? p.volume_cm3 : p.volume); var sv = p.scaleVec || null; return JSON.stringify([p.name || p.fileName || null, num(p.qty, 1), p.tech || null, p.material || null, p.color || null, p.colorCustom || null, p.finish || null, p.quality || null, p.resolution || null, p.infillDensity == null ? null : p.infillDensity, p.finishing || null, sv ? [num(sv.x, 1e6), num(sv.y, 1e6), num(sv.z, 1e6)] : null, num(vol, 1000)]); }).sort(), ]); } catch (e) { basis = ''; } // cyrb53: two 32-bit lanes, so two carts in one tab do not share a slot. var h1 = 0xdeadbeef, h2 = 0x41c6ce57; for (var i = 0; i < basis.length; i++) { var ch = basis.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); var slot = 'mlq-ck-attempt:' + (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36); var store = null; try { store = window.sessionStorage || null; } catch (e) { store = null; } var mem = {}; try { mem = window._ckAttemptKeys || (window._ckAttemptKeys = {}); } catch (e) { mem = {}; } var kept = null; try { kept = store ? store.getItem(slot) : null; } catch (e) { kept = null; } if (!kept) kept = mem[slot] || null; if (kept && /^ML-[0-9A-Z]{10}$/.test(kept)) return kept; // Random, not the clock: two customers pressing in the same millisecond drew // the same reference, and order_ref is unique. var A = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', ref = 'ML-', r = null; try { var cr = (typeof window !== 'undefined' && window.crypto) || (typeof crypto !== 'undefined' ? crypto : null); r = new Uint32Array(10); cr.getRandomValues(r); } catch (e) { r = null; } for (var j = 0; j < 10; j++) ref += A.charAt((r ? r[j] : Math.floor(Math.random() * 4294967296)) % 36); try { if (store) store.setItem(slot, ref); } catch (e) {} mem[slot] = ref; return ref; } // The attempt is over -- an order was confirmed under it, or the server said // nothing ever will be -- so the next press starts a new one. function _ckAttemptDone(ref) { if (!ref) return; try { var store = window.sessionStorage, drop = []; for (var i = 0; i < store.length; i++) { var k = store.key(i); if (k && k.indexOf('mlq-ck-attempt:') === 0 && store.getItem(k) === ref) drop.push(k); } drop.forEach(function (k) { store.removeItem(k); }); } catch (e) {} try { var mem = window._ckAttemptKeys || {}; Object.keys(mem).forEach(function (k) { if (mem[k] === ref) delete mem[k]; }); } catch (e) {} } function placeOrderSuccess(method) { // ONE ORDER THAT IS NOT PAID FOR YET GOES OUT AT A TIME. // // Found in review of the re-ask on 2026-09-29, and reproduced: a refused // "invoice me" drew its question AND switched the original button back on. // Pressed again, that button posted the refused figure; the question's own // button posted the confirmed one while the first was still out; the first // one's refusal came back after the yes, drew the question again and // switched the original on again -- and from then on either button sent the // confirmed figure. Posts 71064, 71064, 71062, 71062: two orders booked, two // confirmations. place-order tells a repeated card or PayPal order apart by // its payment id and nothing else, and an invoice, a bank transfer, store // credit or a test order has no payment id, so each is placed once only if // this page sends it once. Whichever button asks -- the original, the // question's, one redrawn while the first was out, another way to pay -- // nothing more is sent until the one in flight is answered. Card and PayPal // are not held: their money has moved, and the order must be recorded. // // A $0 ORDER TOO ('free': a code that covers the whole order). Nothing is // charged, so like a test order it is only real once place-order has // recorded it -- and it was confirmed the moment it was pressed, before any // answer, under a reference minted fresh on every press. place-order keys it // like the other four (ATTEMPT_KEYED_METHODS), and that never engaged from // this page (the independent check of 84dae1d, 2026-09-30). A refused one // (the code no longer covers the cart) was still shown "Order placed". var _unpaid = method === 'sim' || method === 'credit' || method === 'terms' || method === 'bank' || method === 'free'; if (_unpaid && window._ckPlacingUnpaid) { try { if (typeof showQuoterToast === 'function') showQuoterToast('One moment \u2014 your order is still being placed.'); } catch (e) {} return; } try { window._orderPlaced = true; } catch (e) {} try { qEvent('order_placed', { metadata: { method: method, parts: (typeof _ckAppliedParts === 'function' ? _ckAppliedParts().length : null) }, price: (typeof _ckGrandTotalNum === 'function' ? _ckGrandTotalNum() : null) }); } catch (e) {} window._orderForm = { payEmail: ((document.getElementById('payEmail') || {}).value || ''), payName: ((document.getElementById('payName') || {}).value || ''), payPhone: ((document.getElementById('payPhone') || {}).value || ''), payCompany: ((document.getElementById('payCompany') || {}).value || ''), shipName: (_ckShip && _ckShip.name) || '', shipApt: (_ckShip && _ckShip.line2) || '', billName: (_ckBill && _ckBill.name) || ((ckBillingSameAsShip && _ckShip && _ckShip.name) || ''), ckBillingInput: (_ckBill && [_ckBill.line1, _ckBill.city, _ckBill.state, _ckBill.zip].filter(Boolean).join(', ')) || '' }; const nParts = _ckAppliedParts().length; // THE FIGURE THE ORDER IS PLACED AT: the one _persistOrder sends as // shownCents, which place-order books only when it priced the same. This // printed _ckGrandTotalNum(), the page's own total with no invoicing fee in // it, so a customer who confirmed "Your total is now $689.94" and was // charged $689.94 read $689.96 here, and a Net-30 order invoiced at $710.62 // read $689.96 whether or not anyone had been asked. const _placedCents = (typeof _ckShownCentsFor === 'function') ? _ckShownCentsFor(method) : null; const total = '$' + formatPrice(_placedCents != null ? _placedCents / 100 : _ckGrandTotalNum()); // One reference per checkout attempt for the orders place-order alone books // (_ckAttemptKey). A card or PayPal payment is told apart by its own id. const orderId = (_unpaid && _ckAttemptKey()) || ('ML-' + (Date.now().toString(36).toUpperCase().slice(-6))); // BANK TRANSFER TOO. It was left off this list when the bank button was // added (64ca431), though every line below was written for it -- the 'bank' // message and the unpaid confirmation are both in here -- so a refused bank // order was shown "order placed" while nothing had been recorded. It posts // before any money moves, exactly like terms, and nothing else will ever // create it. The same holds for a PO the account requires (po_required): // refused, it is said in the box under the button, never confirmed. if (_unpaid) { // Neither has a payment backstop. A simulated order is only real once the // server records it; a credit order's PAYMENT is the ledger redemption // inside place-order. Confirming early would tell the customer they paid // while the ledger still shows the credit unspent. window._ckPlacingUnpaid = true; var _pp; try { _pp = _persistOrder(method, orderId); } catch (e) {} Promise.resolve(_pp).then(function (r) { if (r && r.ok) { try { if (typeof _ckReask !== 'undefined' && _ckReask && typeof _ckReask.booked === 'function') _ckReask.booked(); } catch (e) {} Promise.resolve(typeof r.json === 'function' ? r.json().catch(function () { return null; }) : null).then(function (jj) { // Let go in the same turn the confirmation is drawn, so nothing can // be pressed in between. window._ckPlacingUnpaid = false; // The attempt is spent: the next order, even of this same cart, is new. _ckAttemptDone(orderId); // A retry of an attempt that was already booked is answered with THAT // order -- possibly booked on another way of paying, at its own total. // The confirmation says what was booked, not what was pressed last. var _bookedAs = (jj && jj.replayed && jj.payment_method) || method; var _bookedTotal = (jj && jj.replayed && jj.total != null && isFinite(Number(jj.total))) ? '$' + formatPrice(Number(jj.total)) : total; // A bank transfer is PLACED, not paid — the money has not moved yet, and // the order is held in '💵 Awaiting transfer' until it does. Saying 'paid' // here would be the confirmation telling the customer something untrue. // ...and, for a retry answered with its booked order, the figures it // was booked at: the confirmation prints those, not what this page // would work out now (_ckBookedFigures). showOrderConfirmation({ name: '', email: window._orderForm.payEmail, parts: nParts, total: _bookedTotal, paid: _bookedAs !== 'bank', method: _bookedAs, orderId: (jj && jj.order_id) || orderId, booked: (jj && jj.replayed && jj.booked) || null, replayed: !!(jj && jj.replayed), pressed: method }); _ckAnnounceOrderPlaced(); }); } else { window._orderPlaced = false; Promise.resolve(r && typeof r.json === 'function' ? r.json().catch(function () { return null; }) : null).then(function (jj) { window._ckPlacingUnpaid = false; // THE REFERENCE IS KEPT unless this answer says nothing will ever be // booked under it: another account's order carries it, or the credit // balance moved under this attempt (whose redemption the ledger keys // on this reference). A lost connection, a 5xx, a question about the // total -- the order may be booked already, and the same reference is // what gets it back instead of booking it twice. if (jj && (jj.error === 'attempt_taken' || jj.error === 'credit_changed')) _ckAttemptDone(orderId); // A total the server would book instead: asked about, not refused. // The button that was pressed stays OFF while the question is on // screen, so the question's own button is the one way to answer it. // Switched back on, it sent the refused figure beside the confirmed // one -- the two orders at the top of this function. Its words come // back; only its use waits. var _asked = _ckReaskOffer(method, jj); var b = document.getElementById('ckSimPay'); if (b) { b.disabled = _asked && method === 'sim'; b.textContent = 'Place test order \u00b7 simulated payment'; } var cb = document.getElementById('ckCreditPay'); if (cb) { cb.disabled = _asked && method === 'credit'; cb.textContent = window._ckCoverMethod === 'free' ? 'Place order \u00b7 nothing to pay' : 'Place order \u00b7 paid with store credit'; } var tb = document.getElementById('ckTermsPay'); if (tb) { tb.disabled = _asked && method === 'terms'; tb.textContent = 'Place order \u00b7 invoice me'; } var bb = document.getElementById('ckBankPay'); if (bb) { bb.disabled = _asked && method === 'bank'; bb.textContent = 'Place order \u00b7 pay by bank transfer'; } if (_asked) return; var m = document.getElementById('ckCreditPayMsg'); if (m && jj && jj.error === 'credit_changed') m.textContent = 'Your credit balance changed during checkout \u2014 the totals have been refreshed.'; else if (m && jj && jj.error === 'credit_insufficient') m.textContent = 'Your store credit no longer covers this order \u2014 the totals have been refreshed.'; else if (m && jj && jj.error === 'po_required') m.textContent = _ckOrderRefusalText(jj, r && r.status); if (jj && (jj.error === 'credit_changed' || jj.error === 'credit_insufficient')) { _ckFetchCreditBalance(); renderPaymentStep(); } // The invoice button had no failure message at all -- only the two // credit errors above were ever shown, and they write into the CREDIT // element. Everything else fell through to a toast reading "please try // again", for refusals where trying again cannot possibly work: a quote // locked to a colleague's address, or a superseded version. The customer // sees a dead button and we see nothing. (Michelle Cook / Kiosk, // 2026-08-06 -- two silent refusals, no console, no server record.) var tm = document.getElementById('ckTermsPayMsg'); if (tm && method === 'terms') tm.textContent = _ckOrderRefusalText(jj, r && r.status); var bm = document.getElementById('ckBankPayMsg'); if (bm && method === 'bank') bm.textContent = _ckOrderRefusalText(jj, r && r.status); // Inside the .then, because whether retrying can help is decided by // the CODE and not only by the status -- and the code is in the body. var _permanent = _ckRefusalIsPermanent(jj, r && r.status); try { if (typeof showQuoterToast === 'function') showQuoterToast(method === 'sim' ? 'Could not place the test order — please try again.' : (_permanent ? 'Could not place the order — see the message above.' : 'Could not place the order — please try again.')); } catch (e) {} }); } }); return; } try { _ckAdoptOrderNo(_persistOrder(method, orderId), method); } catch (e) {} // Money has moved and the payload is built; a second order on this page // starts with no re-asks counted against it. try { if (typeof _ckReask !== 'undefined' && _ckReask && typeof _ckReask.booked === 'function') _ckReask.booked(); } catch (e) {} showOrderConfirmation({ name: '', email: window._orderForm.payEmail, parts: nParts, total, paid: method !== 'bank', method, orderId }); } // THE ORDER AS IT WAS BOOKED, for a confirmation that answers a retry. // // place-order answers a checkout attempt it has already booked with THAT order // and the figures it was booked at (api/place-order.mjs, bookedMoney). The // confirmation used to be worked out from this page as it stands -- the way of // paying selected now, the store credit and the code as toggled now -- so an // invoice booked at $710.62, whose answer was lost, and then pressed again on // "pay by bank transfer" was confirmed at $689.94 with no invoicing fee: $20.68 // under the invoice the customer would get, on a screen that let them believe // they had chosen a bank transfer (the independent check of 84dae1d, // 2026-09-30). The booked figures are what the customer is invoiced or charged, // so they are what is printed. // // Null unless the figures a total is made of are all there -- a partial answer // is not something to print a receipt from, and the page's own figures stand. function _ckBookedFigures(bk) { if (!bk || typeof bk !== 'object') return null; var n = function (v) { return (v == null || v === '' || !isFinite(Number(v))) ? null : Number(v); }; var out = { subtotal: n(bk.subtotal), tax: n(bk.tax), total: n(bk.total), shipping: n(bk.shipping) || 0, discount: n(bk.discount) || 0, invoiceFee: n(bk.invoiceFee) || 0, materialMinimum: n(bk.materialMinimum) || 0, leadTimeCost: n(bk.leadTimeCost), creditApplied: n(bk.creditApplied) || 0, }; return (out.subtotal == null || out.tax == null || out.total == null) ? null : out; } // Its store credit as it was spent: what the credit took and what was left to // pay. Not the balance on the page now, which that booking already spent from. function _ckBookedCreditRowsHtml(bk) { if (!(bk && bk.creditApplied > 0)) return ''; var due = Math.max(0, Math.round((bk.total - bk.creditApplied) * 100) / 100); return '
    Store credit−$' + formatPrice(bk.creditApplied) + '
    ' + '
    Amount due$' + formatPrice(due) + '
    '; } // The confirmation used to be written straight into .ck-frame with innerHTML, // which destroyed every element the checkout renders into — ckSumBody, ckLeadRows, // ckCrumbs, ckStep3, all of it. Coming back to the quoter and starting a second // order then found none of them: renderCheckout ran, wrote into nothing, and the // only way to order again was a full page reload. Two orders back to back is an // ordinary thing to want. // // It is laid OVER the checkout now. Nothing is destroyed, so nothing has to be // rebuilt, and renderCheckout simply takes the cover off. function _ckShowConfirm(frame, html, pad) { var old = document.getElementById('ckConfirm'); if (old) old.remove(); var wrap = document.createElement('div'); wrap.id = 'ckConfirm'; if (pad) wrap.style.padding = pad; wrap.innerHTML = html; frame.appendChild(wrap); frame.classList.add('ck-confirmed'); return wrap; } // An order that has been placed is no longer a cart. But it is still what the // confirmation is describing — the summary, the timeline and the calendar all // read the applied parts — so the cart is MARKED spent here and emptied on the // way out, never while the customer is still looking at it and never mid-checkout. let _ckCartSpent = false; // The PO was spent with the order too: a reload, or the next order, must not // find it waiting in the box. function _ckMarkCartSpent() { _ckCartSpent = true; try { if (typeof _ckPoForget === 'function') _ckPoForget(); } catch (e) {} } function _ckEmptySpentCart() { if (!_ckCartSpent) return; _ckCartSpent = false; // The code was spent with the order. Carrying it into the next one would // re-apply a discount nobody asked for a second time. _ckTypedCode = null; try { // removePart does the whole teardown — mesh, bbox helper, envelope, probe // callouts, annotations — and the last one restores the upload overlay. parts.slice().forEach(function (p) { try { removePart(p.id); } catch (e) {} }); selectedIds = new Set(); if (typeof renderPartsList === 'function') renderPartsList(); if (typeof renderSelectedPart === 'function') renderSelectedPart(); if (typeof updateGrandTotal === 'function') updateGrandTotal(); } catch (e) { /* an un-emptied cart is better than a broken viewer */ } } function _ckClearConfirm() { var old = document.getElementById('ckConfirm'); if (old) old.remove(); var frame = document.querySelector('#checkoutScreen .ck-frame'); if (frame) { frame.classList.remove('ck-confirmed'); frame.style.padding = ''; } } // place-order mints the REAL order number server-side (production sequence: // 12000+ live, 90100+ test) and returns it as order_id. ML-XXXXXX is a payment // key, not an order number -- nothing on the floor and no /account/orders/ // route can look one up. The confirmation paints with whatever it has, then swaps // the real number in the moment the call lands. function _ckSetOrderNo(no) { if (!no) return; // Only ever a server-minted number. This is called from a few places and one // of them could hand it the ML- reference, which would put the payment key on // screen after the ellipsis had done its job. if (!/^[0-9]+$/.test(String(no))) return; try { window._lastOrderId = String(no); } catch (e) {} var el = document.getElementById('ckcOrderNo'); if (el) el.textContent = String(no); } function _ckAdoptOrderNo(pending, method) { try { var refused = false; var moneyMoved = method === 'card' || method === 'paypal'; Promise.resolve(pending).then(function (r) { // No answer at all (_persistOrder turns a dropped connection into // { ok: false }): the order may well be recorded -- the Stripe webhook // books a card payment itself -- so the confirmation stands. But nobody // heard back either, so once the card or PayPal has been charged staff // are told to check, as accept.html does (the independent delta check, // 2026-10-01). For PayPal it is the only alarm: the capture leaves a // bare row with no parts, which api/paypal-orphans.mjs counts as placed. if (!r || typeof r.json !== 'function') { if (moneyMoved) _ckPaidAnswerUnclear(method, 0); return null; } if (r.ok || !moneyMoved) { if (!r.ok) return null; return r.json().catch(function () { return null; }); } return r.json().then(function (jj) { return jj; }, function () { return undefined; }).then(function (jj) { // A REFUSAL is place-order answering in its own words: a JSON body that // names an error, from a request it did not fail on. Only then has the // card or PayPal been charged for an order that was not recorded. if (_ckIsRefusal(r.status, jj)) { refused = true; _ckPaidNotRecorded(method, jj, r.status); return null; } // Anything else -- a 5xx, a Vercel 502/504 page, a body that is not // place-order's -- says nothing about whether the order was recorded, // and a 500 thrown after the row was written means it WAS. Treated // like a dropped connection: the confirmation stands, and staff are // told to check (the independent check, 2026-10-01, N1). _ckPaidAnswerUnclear(method, r.status); return null; }); }).then(function (jj) { if (refused) return; if (jj && jj.order_id) _ckSetOrderNo(jj.order_id); _ckAnnounceOrderPlaced(); }).catch(function () {}); } catch (e) {} } // Whether an answer from place-order is a refusal: below 500, and a JSON body // naming an error. A server failure, or an error page that is not ours, is not. function _ckIsRefusal(status, jj) { return Number(status) < 500 && !!jj && typeof jj === 'object' && typeof jj.error === 'string' && jj.error.length > 0; } // PAID, ANSWER UNCLEAR: the confirmation stays, and staff are asked to check. // Recorded on the session and reported through the page's reporter (app_errors // and #bugs), naming the payment so the order can be found or finished. function _ckPaidAnswerUnclear(method, status) { var ref = method === 'paypal' ? (window._paypalOrderId || '') : (window._stripePiId || ''); var cents = (typeof window._ckPaidCents === 'number' && window._ckPaidCents > 0) ? window._ckPaidCents : null; try { if (typeof window.mlqReportError === 'function') { window.mlqReportError(new Error('PAID, ANSWER UNCLEAR: ' + method + ' payment ' + (ref || '(no reference)') + (cents != null ? ' of $' + (cents / 100).toFixed(2) : '') + ' went through and place-order answered ' + (status || 'nothing usable') + ', which is not a refusal -- check that the order was recorded'), { component: 'checkout-paid-answer-unclear' }); } } catch (e) {} try { qEvent('checkout_paid_answer_unclear', { metadata: { method: method, ref: ref || null, paidCents: cents, status: status || null } }); } catch (e) {} } // PAID, AND NOT RECORDED: SAY SO, CALMLY, AND TELL US. // // Pre-existing, found 2026-09-30. A card or PayPal payment is taken BEFORE // place-order is asked, so the confirmation is drawn the moment the payment // resolves and the order is recorded behind it. When place-order refused // instead, nothing changed on screen: a card charged $1,088.78 on a page // loaded before the tax-rate change was refused and still read "Order placed". // // What replaces it is what is true: the payment went through, the order was // not recorded automatically, a person will finish it. The payment's own // reference is printed so it can be found. A network failure is NOT this: // the order may well be recorded (the Stripe webhook and the PayPal capture // both record it), so only a refusal place-order actually answered lands here. // // "We've been alerted" is true because of the report just above it: the same // path the rest of this page reports through (app_errors, and #bugs). With no // reporter on the page, the customer is asked to write in instead. function _ckPaidNotRecorded(method, jj, status) { var ref = method === 'paypal' ? (window._paypalOrderId || '') : (window._stripePiId || ''); var cents = (typeof window._ckPaidCents === 'number' && window._ckPaidCents > 0) ? window._ckPaidCents : null; var code = String((jj && jj.error) || ('http_' + (status || 0))); var alerted = false; try { if (typeof window.mlqReportError === 'function') { window.mlqReportError(new Error('PAID, NOT RECORDED: ' + method + ' payment ' + (ref || '(no reference)') + (cents != null ? ' of $' + (cents / 100).toFixed(2) : '') + ' went through and place-order refused the order (' + code + ')'), { component: 'checkout-paid-not-recorded' }); alerted = true; } } catch (e) {} try { qEvent('checkout_paid_not_recorded', { metadata: { method: method, ref: ref || null, paidCents: cents, code: code } }); } catch (e) {} var frame = document.querySelector('#checkoutScreen .ck-frame'); if (!frame) return; _ckShowConfirm(frame, '
    ' + '

    Your payment went through

    ' + '

    ' + 'Your payment' + (cents != null ? ' of $' + formatPrice(cents / 100) : '') + ' went through, but we couldn\u2019t finish recording the order automatically. ' + (alerted ? 'We\u2019ve been alerted and will email you to confirm, usually within one business day.' : 'Please email hello@makelab.com with the reference below and we\u2019ll confirm it, usually within one business day.') + '

    ' + (ref ? '

    Payment reference: ' + _escHtml(ref) + '

    ' : '') + '
    ', ''); } // portal.js caches each screen's data, and it PREFETCHES orders on idle -- which // on a checkout page is always before the order exists. Telling it the moment // place-order returns is what makes "Track your order" show the order the // customer just placed instead of needing a full page refresh. function _ckAnnounceOrderPlaced() { try { window.dispatchEvent(new CustomEvent('mlq:order-placed')); } catch (e) {} } function showOrderConfirmation(o) { o = o || {}; var paid = !!o.paid, email = o.email || '', parts = o.parts || 0, total = o.total || '', method = o.method || ''; // Prefer the real quote number. The fallback generates an ML-XXXXXX string // client-side that exists in no system -- a quote request showed it as the // Reference and told the customer to email us about it, so anyone who did was // quoting a number nobody could look up. var orderId = o.quoteNo || o.orderId || ('ML-' + (Date.now().toString(36).toUpperCase().slice(-6))); window._lastOrderId = orderId; // What the CUSTOMER reads -- only ever a number a server minted. // // orderId above keeps its client-generated fallback because it is the // correlation key for feedback and for _persistOrder's idempotency. But that // string exists in no system a human can look up, and painting it meant the // screen showed "Order ML-PJ2X4K" for a beat before _ckSetOrderNo swapped in // the real number -- Christina watched 11540 do exactly that on 2026-08-12. // Showing a customer an id we are about to retract is worse than showing none. // Strictly a server-minted id: a quote number (Q-...) or an order number. // // This took o.orderId unfiltered, and o.orderId is frequently the // client-minted ML-XXXXXX — so the thank-you page printed "Order ML-PJ2X4K" // to the customer. Christina, 2026-08-12: "what can you do about the // reference number that shows on the thank you page, can you get rid of that // too?" // // With neither, the id renders as an ellipsis and _ckSetOrderNo fills in the // real number the moment place-order answers. A brief ellipsis is honest; an // id we are about to retract is not. // No displayed id is computed for the paid confirmation any more -- it shows // none. The RFQ branch below renders its own quote number from o.quoteNo, // which is server-minted and stable, and is unaffected. var body = document.querySelector('#checkoutScreen .ck-frame'); if (!body) return; var _ckConfirmHtml = ''; if (!paid) { // Variant C, chosen by Christina 2026-08-01. It leads with WHY the request // needs a person and WHEN the answer comes, because those are the two // questions a customer actually has here. The reason text is the server's // (place-order returns pickReason's answer), so this page and the // acknowledgment email cannot say different things about the same request. var _rr = o.reason || {}; var _rLine = _rr.line || 'Something in this request needs an engineer to look at it before we can price it accurately.'; var _rCard = _rr.card || 'The specific detail that flagged, and whether it changes the price or the process.'; // Only a SERVER-minted number may be shown as the RFQ number. The orderId // fallback is a client-generated ML-XXXXXX that exists in no system, and // printing it under an "RFQ" label invites the customer to quote back a // number nobody can look up -- the exact bug the old Quote/Reference label // was there to prevent. var _hasNo = !!o.quoteNo; _ckConfirmHtml = '
    ' + '
    ' + (_hasNo ? 'RFQ ' + _escHtml(orderId) + ' · received just now' : 'RFQ received just now') + '
    ' + '

    An engineer has your RFQ

    ' + '

    ' + _escHtml(_rLine) + ' That’s the only reason it isn’t instant.

    ' + '
    ' + '

    Parts

    ' + parts + '

    ' + (total ? '

    Estimated

    ' + _escHtml(total) + '

    ' : '') + (email ? '

    Copy sent to

    ' + _escHtml(email) + '

    ' : '') + '
    ' + '

    What happens next

    ' + '
    Received' + (_hasNo ? 'Saved as ' + _escHtml(orderId) + ', with every file and note you added.' : 'Saved with every file and note you added.') + '
    ' + '
    An engineer reviews it' + _escHtml(_rCard) + '
    ' + '
    Firm quote, within 1 hourIf you sent this before 4:30 PM ET. Otherwise first thing next business day.
    ' + '
    You approve, we startNothing is charged until you do.
    ' + '
    ' + '
    ' + 'View my RFQs
    ' + '
    ' + '

    Check your spam — if you don’t hear back inside that window, look in spam or junk. Our replies sometimes land there.

    ' + '' + '
    '; _ckShowConfirm(body, _ckConfirmHtml, '40px 28px 48px'); var sb0 = document.getElementById('confirmStartAnother'); if (sb0) sb0.addEventListener('click', function () { window.location.href = '/'; }); return; } var tier = (typeof _ckSelectedTier === 'function') ? _ckSelectedTier() : null; var prodStart = (typeof productionStart === 'function') ? productionStart() : new Date(); var ready = (tier && typeof ckLeadChosen !== 'undefined' && ckLeadChosen) ? addBusinessDays(prodStart, tier.businessDays) : null; var delMethod, delDate; if (orderDispatch === 'pickup') { delMethod = 'Pickup · 13 42nd St'; delDate = ready; } else if (orderDispatch === 'courier') { delMethod = 'Courier'; delDate = ready; } else { var _cfr = (typeof ckSelectedRate !== 'undefined' && ckSelectedRate) || null; delMethod = _cfr ? (_cfr._label || 'UPS') : 'Shipping'; // Freight has no arrival until a person quotes it. This fell back to the // parts-ready date, so the confirmation dated a delivery nobody had booked. delDate = (_cfr && _cfr._freight) ? null : ((_cfr && _cfr._arrival) ? _cfr._arrival : ready); } var _cfrTbd = !!(orderDispatch === 'shipping' && typeof ckSelectedRate !== 'undefined' && ckSelectedRate && ckSelectedRate._freight); var fmt = function (d) { return d ? _rfqTilde() + formatDate(d) : 'TBD'; }; var shippedLbl = (orderDispatch === 'pickup') ? 'Ready for pickup' : 'Shipped'; var help = 'https://help.makelab.com'; function faq(q) { return '' + q + '→'; } var faqs = (orderDispatch === 'pickup') ? [faq('What are your pickup hours and where do I go?'), faq('What do I need to bring for pickup?')] : [faq('How do I track my order?'), faq('What if my package is lost or damaged?')]; faqs = faqs.concat([faq('Can I reorder these parts later?'), faq('What if I need to change my order?')]); var _applied = (typeof _ckAppliedParts === 'function') ? _ckAppliedParts() : []; // The SAME split the checkout summary uses, not a second derivation. // // This block used to call computeOrderMinimums directly, which prices parts at // the CHOSEN tier. So the receipt a customer saw after paying showed a // Subtotal with the expedite charge baked into it, sitting above a Lead time // row stating that charge again -- while the checkout page one click earlier // showed parts at the baseline with the delta on its own line. Same order, two // different Subtotals, and neither page agreed with the confirmation email. // // _ckTotalsSplit is the one place that decides where the lead-time money sits // (and it honours a locked quote's issued.leadTimeAmount rather than // re-deriving it). Both surfaces read it now. var _splitC = _ckTotalsSplit(_applied, orderDeliveryTier, ckLeadChosen); var _mins = _splitC.base; var _leadDeltaC = _splitC.leadDelta; var _escc = function (v) { return (v == null ? '' : String(v)).replace(/[&<>"']/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; }); }; var _sumRows = _ckSummaryRows(_applied); var _shipCost = (ckDispatchChosen && orderDispatch === 'shipping' && ckSelectedRate) ? ckSelectedRate.rate : ((ckDispatchChosen && orderDispatch === 'courier') ? 15 : 0); // Tender order copied from the checkout summary deliberately: parts + lead, // then shipping, then the discount, THEN the 3% invoicing fee, then tax on all // of it. Any other order charges the customer a different number. var _orderTotalC = _mins.grandTotal + _leadDeltaC; var _discC = (typeof _ckDiscountAmount === 'function') ? _ckDiscountAmount(_orderTotalC) : 0; var _financedC = _orderTotalC + _shipCost - _discC; // Omitting this understated the Total on every Net-30 receipt by exactly the // 3% we then invoiced -- the customer's own copy disagreed with their bill. // The fee for the way THIS ORDER is paid, never the box selected now: the // two part company when another box is clicked while the order is out, or // when a retry is answered with an order booked another way. var _feeC = (typeof _ckInvoiceFeeAmount === 'function') ? _ckInvoiceFeeAmount(_financedC, method) : 0; var _pretaxC = Math.round((_financedC + _feeC) * 100) / 100; var _taxC = _ckTaxOn(_pretaxC); var _grandC = _pretaxC + _taxC; var _delLbl = orderDispatch === 'pickup' ? 'Local pickup' : (orderDispatch === 'courier' ? 'Courier' : 'Shipping'); var _delVal = orderDispatch === 'pickup' ? 'Free' : (orderDispatch === 'courier' ? '$15.00' : (ckSelectedRate ? (ckSelectedRate._freight ? 'TBD' : '$' + ckSelectedRate.rate.toFixed(2)) : '—')); // AN ORDER ALREADY BOOKED IS DESCRIBED AS IT WAS BOOKED (_ckBookedFigures): // every money row from the booking, the store credit as it was spent, and // the parts subtotal split out of the booked one the way the rows add up // (subtotal - lead time - minimum, lib/server-price.mjs). var _bk = (typeof _ckBookedFigures === 'function') ? _ckBookedFigures(o.booked) : null; var _subRowC = _mins.subtotal, _minRowC = _mins.adjustmentTotal, _creditRowsC = null; if (_bk) { if (_bk.leadTimeCost != null) _leadDeltaC = _bk.leadTimeCost; _minRowC = _bk.materialMinimum; _subRowC = Math.round((_bk.subtotal - _leadDeltaC - _minRowC) * 100) / 100; _discC = _bk.discount; _feeC = _bk.invoiceFee; _taxC = _bk.tax; _grandC = _bk.total; if (orderDispatch !== 'pickup' && _delVal !== 'TBD') _delVal = _bk.shipping > 0 ? '$' + formatPrice(_bk.shipping) : 'Free'; _creditRowsC = _ckBookedCreditRowsHtml(_bk); } // Said once, above the rows, when this answer was a retry's: nothing new was // placed -- and how it was booked, when that is not the button just pressed. var _asBooked = { terms: 'to be invoiced', bank: 'to be paid by bank transfer', credit: 'as paid with store credit', free: 'at no charge', sim: 'as a test order', card: 'as paid by card', paypal: 'as paid with PayPal' }; var _replayNote = o.replayed ? 'This order had already gone through, so nothing new was placed.' + (o.pressed && o.pressed !== method && _asBooked[method] ? ' It is booked ' + _asBooked[method] + '.' : '') : ''; var _totalsC = '
    Subtotal$' + formatPrice(_subRowC) + '
    ' + // The order-minimum top-up. The checkout summary has always shown this row; // the receipt dropped it, so a customer whose parts came to $14.09 saw a // Subtotal of $14.09 and a Total built on $25.00 with nothing in between to // explain the jump (order 11538, +$10.91). (_minRowC > 0 ? '
    Material minimum adjustment+$' + formatPrice(_minRowC) + '
    ' : '') + '
    Lead time' + (tier ? tier.name + ' · ' + tier.businessDays + ' biz day' + (tier.businessDays === 1 ? '' : 's') + ' · ready ' + fmt(ready) : '') + '' + ((typeof _ckLeadAmtText === 'function') ? _ckLeadAmtText(_leadDeltaC) : (tier ? leadTimeAmount(_ckAppliedParts(), tier.key, true) : '—')) + '
    ' + '
    Delivery' + _delLbl + '' + _delVal + '
    ' + (_discC > 0 ? '
    Discount−$' + formatPrice(_discC) + '
    ' : '') + (_feeC > 0 ? '
    Invoicing fee (3%)$' + formatPrice(_feeC) + '
    ' : '') + '
    ' + _ckTaxLabel() + '$' + formatPrice(_taxC) + '
    Total$' + formatPrice(_grandC) + '
    ' // Store credit spent on the order. It showed on the checkout page and then // vanished from the receipt, so a customer who paid part of an order with // credit saw a Total they had not actually been charged. No toggle here -- // the money is already spent, this is a record of it, not a choice. + (_creditRowsC != null ? _creditRowsC : ((typeof _ckCreditRowsHtml === 'function') ? _ckCreditRowsHtml(_grandC, { toggle: false }) : '')); _ckConfirmHtml = '' + '
    ' + '
    ✓

    Order placed — you’re all set!

    ' // No order number on this screen. // // Christina, 2026-08-13: "why dont we just remove the order number from // this page? since it's a timing thing". It was: the confirmation renders // the instant the payment resolves, and the number arrives on the // place-order response a moment later, so what it printed depended on which // of the two landed first. Order 11554 had its number in the database and // in the customer's email, and this screen still showed an ellipsis. // // The receipt is the surface that carries the number, and it is sent within // seconds. A screen that shows the number sometimes is worse than one that // never promises it -- and strictly better than the ML- payment key it used // to fall back to, which exists in no system anyone can look up. + '
    ' + (email ? 'Confirmation sent to ' + email : '') + '
    ' + (_replayNote ? '
    ' + _escc(_replayNote) + '
    ' : '') + '
    ' + '
    Production starts' + fmt(prodStart) + '
    ' + '
    Ready' + fmt(ready) + '
    ' + '
    ' + (orderDispatch === 'pickup' ? 'Pickup' : 'Delivery') + '' + (delDate ? fmt(delDate) : (_cfrTbd ? 'TBD' : '\u2014')) + '
    ' + '
    ' + '
    ' // The production calendar sits RIGHT under the timeline (Christina, // 2026-08-01) -- it answers the same question the timeline raises, so the // order summary must not wedge between them. + '
    ' + '
    ' // Per-part rows are BACK (Christina, 2026-08-14: "put back the pages"). // // They came off on 2026-08-14 because they could disagree with what the // floor ends up making -- order 11566 listed 52 parts and 25 reached the // floor with a picture. That disagreement is now prevented upstream rather // than hidden here: an order whose parts do not all resolve to their model // file HOLDS, and nothing generates off it, so the floor cannot quietly end // up with a different set of parts than this screen shows. // // The pictures are the ones the customer already saw in the cart, drawn by // generatePartThumbnail -- which now renders the approved style, so this // screen and the printed page finally show the same picture. + '
    Order summary
    ' + _sumRows + '
    ' + _totalsC + '
    ' + '
    ' + '
    ' // These pointed at the marketing sign-in page, in a new tab, for a customer // who had just signed in and ordered. They open the account they already have. + '' + '' + '' + '
    ' + '

    Questions you might have

    ' + faqs.join('') + 'More questions →
    ' + '
    '; _ckShowConfirm(body, _ckConfirmHtml, '0'); // Paid: the cart is spent. Emptied when the customer leaves this page, not now // — the summary, timeline and calendar below are still describing it. try { _ckMarkCartSpent(); } catch (e) {} if (typeof renderCheckoutTimeline === 'function') renderCheckoutTimeline(); if (typeof renderLeadCalendar === 'function') renderLeadCalendar(); document.querySelectorAll('[data-ckc-go]').forEach(function (b) { b.addEventListener('click', function () { var where = b.getAttribute('data-ckc-go'); if (where === 'quoter') { window.location.href = '/'; return; } // The account lives in portal.js, the same module the sidebar opens. import('/portal.js').then(function (m) { m.openPortal(where); }).catch(function () { window.location.href = '/'; }); // Warm the account portal once the page is idle. portal.js is 58 KB gzipped and // was fetched ON THE CLICK -- measured at 281ms before openPortal could even // start, which is most of why "viewer to models feels delayed". It has no // top-level side effects (declarations only), so importing early costs nothing // but the fetch, and the module cache makes the click instant. (function () { var warmed = false; function warmPortal() { if (warmed) return; warmed = true; import('/portal.js').catch(function () { warmed = false; }); } var idle = window.requestIdleCallback || function (fn) { return setTimeout(fn, 1500); }; if (document.readyState === 'complete') idle(warmPortal, { timeout: 4000 }); else window.addEventListener('load', function () { idle(warmPortal, { timeout: 4000 }); }, { once: true }); // A pointer heading for an account link beats the idle timer to it. document.addEventListener('pointerdown', function (e) { if (e.target && e.target.closest && e.target.closest('[data-nav],[data-act]')) warmPortal(); }, { capture: true, passive: true }); })(); }); }); _mountFeedback(orderId); } function _mountFeedback(orderId) { var old = document.getElementById('ckcFeedback'); if (old) old.remove(); var fb = document.createElement('div'); fb.id = 'ckcFeedback'; fb.innerHTML = '' + '

    How was your experience?

    ' + '

    Anything we could improve — and what features would you like to see in the future?

    ' + '' + ''; document.body.appendChild(fb); setTimeout(function () { fb.classList.add('in'); }, 1300); function close() { fb.classList.remove('in'); setTimeout(function () { if (fb.parentNode) fb.remove(); }, 450); } document.getElementById('ckcFbX').addEventListener('click', close); document.getElementById('ckcFbSend').addEventListener('click', function () { var t = (document.getElementById('ckcFbText').value || '').trim(); try { fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId: (window._lastOrderId || orderId), text: t, email: (window._orderForm && window._orderForm.payEmail) || '' }) }).catch(function () {}); } catch (e) {} fb.innerHTML = '

    Thank you! 🙏

    We read every note — it shapes what we build next.

    '; document.getElementById('ckcFbX2').addEventListener('click', close); setTimeout(close, 2800); }); } // ══════════════════════════════════════════════════════════════════ // Config sidebar: accordion + material thumbnail cards // Collapses tech / material / color / infill to a summary row on // select; click a summary to re-open. Quantity stays always-open. // Everything shown here obeys the pricing model's external visibility. // ══════════════════════════════════════════════════════════════════ (function () { if (typeof document === 'undefined') return; var IMG_BASE = 'https://imagedelivery.net/_b-GGY1-wAD4kOMdqDR7IQ/'; var IMG_VARIANT = '/format=auto,quality=auto,w=600'; // Served from OUR origin via /api/img. A page on quote.makelab.com asking // imagedelivery.net directly is a third-party request, which is the shape ad // blockers and privacy extensions match -- 20 blocked images were filed in // the 30 days to 2026-08-22, every URL fine when fetched from anywhere else. function img(id) { return '/api/img?u=' + encodeURIComponent(IMG_BASE + id + IMG_VARIANT); } // Material key (matches pricing model) → makelab.com Cloudflare thumbnail. var MATERIAL_THUMBS = { pla: img('website-v2_images_part-pla-pattern'), petg: img('website-v2_images_part-petg'), asa: img('website-v2_media_asa-10864-IMG_5217_q8rwx1'), m_1779910726430: img('website-v2_media_10481--2025_12_05--2'), // TPU m_1779910732598: img('website-v2_media_pc-cf-DSC_5755_ctverec-1'), // PC-CF fgf_abs: img('website-v2_media_abs-10602--IMG_3121_upj7km'), fgf_petg: img('website-v2_images_part-petg'), nylon12: img('website-v2_media_nylonpa12-10298--2025_10_30--1'), nylon11: img('website-v2_media_nylonpa11-Nylon-PA11-3D-printing'), gf_nylon: img('website-v2_media_nylonpa12-10821-IMG_5050_dywy1i'), standard: img('website-v2_media_resin-10704--2026_02_09--2'), durable: img('website-v2_media_durable-10764-IMG_4454_x4aodt'), flexible: img('website-v2_media_flexible-10897-IMG_5553'), tough2000: img('website-v2_media_tough2k-10795-IMG_4957_snz7f1'), tough1500: img('website-v2_media_tough1500-9333--2025_04_02--13'), rigid4k: img('website-v2_media_rigid4k-9272--2025_03_25--16'), greypro: img('dispatch_pics_9936_9936--2025_08_08--6'), castable: img('website-v2_media_castable-10509--2025_12_18--1'), draft: img('website-v2_media_resin-10704--2026_02_09--2'), hightemp: img('website-v2_media_hightemp-5911--2022_12_29--4'), abs_light: img('website-v2_media_abslike-10794--19-10794-IMG_4885'), frost: img('website-v2_media_frosted-pmma-honeycomb'), clear: img('website-v2_media_optclear-10459--2025_12_12--34') }; var MATERIAL_FALLBACK = img('website-v2_media_resin-10704--2026_02_09--2'); var ORDER = ['tech', 'material', 'color', 'finishing', 'quality', 'infill', 'qty']; var LABELS = { tech: 'Technology', material: 'Material', quality: 'Quality', color: 'Color', finishing: 'Finishing', infill: 'Infill', qty: 'Quantity' }; var _accLastPartId = null; function selPart() { if (typeof parts === 'undefined' || typeof selectedIds === 'undefined') return null; return parts.find(function (p) { return selectedIds.has(p.id); }) || null; } function accEl(name) { return document.querySelector('.cfg-acc[data-acc="' + name + '"]'); } // A step is only available once its prerequisite is chosen (progressive reveal). function availability(name, sp) { if (!sp) return false; if (name === 'tech') return true; if (name === 'material') return sp.tech != null; if (name === 'quality') { if (sp.material == null) return false; var _gk = (typeof TECH_TO_GROUP !== 'undefined') ? TECH_TO_GROUP[sp.tech] : null; if (_gk === 'kings') return false; // Industrial SLA: no quality step var _g = (_gk && PRICING_MODEL && PRICING_MODEL.groups) ? PRICING_MODEL.groups[_gk] : null; // Quality (resolution) only applies to resin/FDM — MJF bakes it, FGF uses legacy pricing. if (!_g || (_g.type !== 'fdm' && _g.type !== 'sla')) return false; return (typeof getQualityOptions === 'function') && getQualityOptions(_g, sp.material).length > 0; } if (name === 'color') return sp.material != null; if (name === 'finishing') { // Only a step when we actually finish something in this material. if (sp.material == null) return false; return (typeof finServicesFor === 'function') && finServicesFor(sp).length > 0; } if (name === 'infill') return sp.material != null && sp.tech === 'fdm'; if (name === 'qty') return sp.material != null; // reveal (and auto-open) once material is chosen return true; } function valueSet(name, sp) { if (!sp) return false; if (name === 'tech') return sp.tech != null; if (name === 'material') return sp.material != null; if (name === 'quality') return sp.resolution != null; if (name === 'color') return sp.color != null; if (name === 'finishing') return true; // optional — 'None' is a real answer, never blocks the walk if (name === 'infill') return sp.infillDensity != null; if (name === 'qty') return true; return false; } function firstIncomplete(sp) { for (var i = 0; i < ORDER.length; i++) { var nm = ORDER[i]; if (availability(nm, sp) && !valueSet(nm, sp)) return nm; } return null; } function setOpen(name, open) { var el = accEl(name); if (!el) return; if (name === 'qty') open = true; // Quantity is always open el.classList.toggle('cfg-open', open); el.classList.toggle('cfg-collapsed', !open); } // Each section's open/closed state is independent. refreshAccordion only manages // availability (progressive reveal) + summaries; it never force-closes a step you opened. function refreshAccordion() { var sp = selPart(); ORDER.forEach(function (name) { var el = accEl(name); if (!el) return; var avail = availability(name, sp); var wasHidden = el.classList.contains('cfg-hidden'); el.classList.toggle('cfg-hidden', !avail); if (!avail) { el.classList.remove('cfg-open'); el.classList.add('cfg-collapsed'); } else if (wasHidden) { setOpen(name, true); } // newly revealed → open it and keep it open else if (!el.classList.contains('cfg-open') && !el.classList.contains('cfg-collapsed')) { el.classList.add('cfg-collapsed'); } if (name === 'qty' && avail) setOpen('qty', true); // Quantity stays open if (avail && name === 'infill' && typeof renderInfillChips === 'function') { try { renderInfillChips(); } catch (e) {} } if (avail && name === 'quality') { try { renderQualityChips(); } catch (e) {} } if (avail && name === 'finishing' && typeof renderFinishing === 'function') { try { renderFinishing(); } catch (e) {} } var summary = el.querySelector('.cfg-acc-summary'); if (summary) buildSummary(summary, name); }); updateExpandAllBtn(); } // Header click toggles ONLY that step (others keep their state — stay open unless you close them). function expandSection(name) { var el = accEl(name); if (!el) return; setOpen(name, !el.classList.contains('cfg-open')); refreshAccordion(); } // Picking a value closes that step and opens the next unfinished one (auto-advance), // without touching any other step you've manually opened. function advance(fromName) { if (fromName) setOpen(fromName, false); var next = firstIncomplete(selPart()); if (next) setOpen(next, true); else setAllOpen(false); // config complete → collapse every section refreshAccordion(); } // Re-reveal every step below `fromName` so the progressive auto-open re-fires // when an upstream choice changes — no matter what the user had open or closed. // (Marks them cfg-hidden; refreshAccordion then treats re-availability as a fresh reveal.) function resetDownstream(fromName) { var idx = ORDER.indexOf(fromName); if (idx < 0) return; for (var i = idx + 1; i < ORDER.length; i++) { var el = accEl(ORDER[i]); if (el) el.classList.add('cfg-hidden'); } } function anyCollapsed() { var sp = selPart(); return ORDER.some(function (name) { var el = accEl(name); return el && availability(name, sp) && el.classList.contains('cfg-collapsed'); }); } function setAllOpen(open) { var sp = selPart(); ORDER.forEach(function (name) { if (availability(name, sp)) setOpen(name, open); }); refreshAccordion(); } function updateExpandAllBtn() { var btn = document.getElementById('accExpandAll'); if (!btn) return; btn.textContent = anyCollapsed() ? 'Expand all' : 'Collapse all'; } function escapeAcc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function techLabel(val) { var t = document.querySelector('#techChips .chip-card[data-value="' + val + '"] .chip-card-title'); return t ? t.textContent.trim() : (val || ''); } function matLabel(sp) { if (!sp) return ''; var opt = document.querySelector('#cfgMaterial option[value="' + (sp.material || '') + '"]'); return opt ? opt.textContent.trim() : (sp.material || ''); } var COLOR_LABELS = { natural: 'Natural', white: 'White', black: 'Black', gray: 'Gray', clear: 'Clear', custom: 'Custom' }; var COLOR_DOTS = { natural: '#d9d4c9', white: '#f5f5f5', black: '#1a1a1a', gray: '#9a9a9a', clear: 'rgba(150,180,200,0.45)', custom: 'conic-gradient(red,orange,yellow,green,blue,violet,red)' }; function buildSummary(el, name) { var sp = selPart(), valueHtml = ''; var PH = 'Select…'; if (name === 'tech') { valueHtml = (sp && sp.tech) ? '' + escapeAcc(techLabel(sp.tech)) + '' : PH; } else if (name === 'material') { // Text-only header (no thumbnail) to keep the collapsed rows calm. valueHtml = (sp && sp.material) ? '' + escapeAcc(matLabel(sp)) + '' : PH; } else if (name === 'color') { if (sp && sp.color) { valueHtml = '' + escapeAcc(COLOR_LABELS[sp.color] || sp.color) + ''; } else valueHtml = PH; } else if (name === 'quality') { valueHtml = (sp && sp.resolution) ? '' + escapeAcc(qualityLabel(sp.resolution)) + '' : PH; } else if (name === 'finishing') { var _fk = sp && sp.finishing && sp.finishing[0] && sp.finishing[0].key; if (_fk) { var _fs = (typeof finServicesFor === 'function') ? finServicesFor(sp).find(function (x) { return x.key === _fk; }) : null; valueHtml = '' + escapeAcc((_fs && _fs.label) || _fk) + ''; } else valueHtml = 'None'; } else if (name === 'infill') { valueHtml = (sp && sp.infillDensity != null) ? '' + Math.round(sp.infillDensity * 100) + '%' : PH; } else if (name === 'qty') { var _q = sp ? sp.qty : 1; valueHtml = '' + (_q || 1) + (_q === 1 ? ' unit' : ' units') + ''; } el.innerHTML = '' + LABELS[name] + '' + '' + valueHtml + '' + ''; } function updateAccordionSummaries() { ORDER.forEach(function (name) { var el = accEl(name); if (!el) return; var summary = el.querySelector('.cfg-acc-summary'); if (summary) buildSummary(summary, name); }); } function renderMaterialCards() { var host = document.getElementById('materialCards'); if (!host) return; var sp = selPart(); if (!sp || typeof getMatOptions !== 'function') { host.innerHTML = ''; return; } var opts = getMatOptions(sp.tech) || []; host.innerHTML = opts.map(function (o) { var key = o[0], label = o[1]; var src = MATERIAL_THUMBS[key] || MATERIAL_FALLBACK; var active = key === sp.material ? ' active' : ''; var info = (typeof materialPageInfo === 'function') ? materialPageInfo(sp.tech, key) : null; var desc = (info && info.notes) ? '' + escapeAcc(info.notes) + '' : ''; var learn = (info && info.url) ? 'Learn more \u2192' : ''; return ''; }).join(''); host.querySelectorAll('.mat-card-learn').forEach(function (lk) { var go = function (e) { e.stopPropagation(); e.preventDefault(); var u = lk.dataset.url; if (u) window.open(u, '_blank', 'noopener'); }; lk.addEventListener('click', go); lk.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') go(e); }); }); host.querySelectorAll('.mat-card').forEach(function (card) { card.addEventListener('click', function () { var key = card.dataset.matkey; var sel = document.getElementById('cfgMaterial'); if (sel) { sel.value = key; sel.dispatchEvent(new Event('change', { bubbles: true })); } host.querySelectorAll('.mat-card').forEach(function (c) { c.classList.toggle('active', c === card); }); updateAccordionSummaries(); // Material chosen -> a price is generated. Reveal the remaining steps (so // they're expandable) but collapse everything so the Part Price shows. var _sp = selPart(); ORDER.forEach(function (nm) { var el = accEl(nm); if (el && availability(nm, _sp)) { el.classList.remove('cfg-hidden'); setOpen(nm, false); } }); refreshAccordion(); var _pb = document.querySelector('.part-price-box'); if (_pb && _pb.scrollIntoView) { try { _pb.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (e) {} } }); }); } function qualityNum(label){ var m = String(label || '').match(/(\d+)/); return m ? parseInt(m[1], 10) : null; } function qualityWord(label){ var t = String(label || ''); var i = t.indexOf('-'); return (i > 0 ? t.slice(0, i) : t).trim(); } function qualityLabel(k){ var fo = PRICING_MODEL && PRICING_MODEL.factorOptions && PRICING_MODEL.factorOptions.quality; var full = k; if (Array.isArray(fo)) { var f = fo.find(function(o){ return String(o.key) === String(k); }); if (f) full = f.label || k; } return qualityWord(full); // header shows the word (Standard / Draft / Detailed) } function renderQualityChips(){ var host = document.getElementById('qualityChips'); if (!host) return; var sp = selPart(); if (!sp) { host.innerHTML = ''; return; } var gk = (typeof TECH_TO_GROUP !== 'undefined') ? TECH_TO_GROUP[sp.tech] : null; var g = (gk && PRICING_MODEL && PRICING_MODEL.groups) ? PRICING_MODEL.groups[gk] : null; var raw = (typeof getQualityOptions === 'function') ? getQualityOptions(g, sp.material) : []; // Ticks show µm numbers, sorted numerically; the value is still the option key. var opts = raw.map(function(o){ return { key: o[0], num: qualityNum(o[1]), word: qualityWord(o[1]) }; }) .sort(function(a, b){ return (a.num == null ? 1e9 : a.num) - (b.num == null ? 1e9 : b.num); }) .map(function(o){ return [o.key, (o.num != null ? (o.num + ' µm') : o.word)]; }); if (typeof buildSnapSlider !== 'function') { host.innerHTML = ''; return; } buildSnapSlider(host, opts, function(v){ return v === sp.resolution; }, function(value, label, final){ var selected = parts.filter(function (pp) { return selectedIds.has(pp.id); }); if (!selected.length) return; selected.forEach(function (pp) { pp.resolution = value; }); if (typeof updatePartPrice === 'function') updatePartPrice(selected); updateAccordionSummaries(); // Quality does not auto-advance — it stays open until you close it. }, function(key, label){ var n = parseInt(label, 10); if (!n) return 'Standard surface detail.'; if (n >= 300) return 'Draft — fastest and lowest cost; visible layer lines.'; if (n >= 200) return 'Standard — a solid balance of detail, speed and cost.'; if (n >= 100) return 'Fine — crisp detail for visible surfaces; costs more.'; return 'Ultra-fine — highest detail for tiny features; slowest.'; }); } window.__renderMaterialCards = renderMaterialCards; window.__updateAccordionSummaries = updateAccordionSummaries; window.__advanceStep = function (name) { advance(name); }; window.__syncAccordionToPart = function () { var sp = selPart(); renderMaterialCards(); var id = sp ? sp.id : null; if (id !== _accLastPartId) { _accLastPartId = id; ORDER.forEach(function (nm) { setOpen(nm, false); }); // fresh part → collapse all… if (sp) setOpen('tech', true); // …then open Technology } refreshAccordion(); }; function initConfigAccordion() { var view = document.getElementById('selectedPartView'); if (!view) return; // Reorder: Quantity right after Material; inject Quality right after Color. var _grp = function (txt) { return Array.prototype.slice.call(view.querySelectorAll('.config-group')).find(function (gp) { var l = gp.querySelector('.config-label'); return l && l.textContent.trim() === txt; }); }; var _matGroup = _grp('Material'), _qtyGroup = _grp('Quantity'), _colorGroup = _grp('Color'); if (!document.getElementById('qualityChips') && _colorGroup) { var _qg = document.createElement('div'); _qg.className = 'config-group'; _qg.innerHTML = '
    Quality
    '; _colorGroup.parentNode.insertBefore(_qg, _colorGroup.nextSibling); } // Enforce step order in the DOM; Quantity is always the last step. var _accParent = _matGroup && _matGroup.parentNode; if (_accParent) ['Technology', 'Material', 'Color', 'Finishing', 'Quality', 'Infill Density', 'Quantity'].forEach(function (t) { var g = _grp(t); if (g) _accParent.appendChild(g); }); var accMap = { 'Technology': 'tech', 'Material': 'material', 'Quality': 'quality', 'Color': 'color', 'Finishing': 'finishing', 'Infill Density': 'infill', 'Quantity': 'qty' }; view.querySelectorAll('.config-group').forEach(function (group) { var label = group.querySelector('.config-label'); var name = label ? accMap[label.textContent.trim()] : null; if (!name) return; if (group.classList.contains('cfg-acc')) return; group.classList.add('cfg-acc', 'cfg-collapsed'); group.setAttribute('data-acc', name); var body = document.createElement('div'); body.className = 'cfg-acc-body'; while (group.firstChild) body.appendChild(group.firstChild); var summary = document.createElement('button'); summary.type = 'button'; summary.className = 'cfg-acc-summary'; summary.addEventListener('click', function () { expandSection(name); }); group.style.display = ''; // clear any inline display:none (infill group) — accordion owns visibility now group.appendChild(summary); group.appendChild(body); if (name === 'material') { var sel = body.querySelector('#cfgMaterial'); if (sel) sel.style.display = 'none'; if (!body.querySelector('#materialCards')) { var grid = document.createElement('div'); grid.id = 'materialCards'; grid.className = 'material-card-grid'; var lbl = body.querySelector('.config-label'); if (lbl && lbl.nextSibling) body.insertBefore(grid, lbl.nextSibling); else body.appendChild(grid); } } }); // Expand / Collapse-all toggle at the top of the config. var firstAcc = view.querySelector('.cfg-acc'); if (firstAcc && !document.getElementById('accExpandAll')) { var bar = document.createElement('div'); bar.className = 'cfg-acc-toolbar'; var allBtn = document.createElement('button'); allBtn.type = 'button'; allBtn.id = 'accExpandAll'; allBtn.className = 'cfg-acc-toolbar-btn'; allBtn.textContent = 'Expand all'; allBtn.addEventListener('click', function () { setAllOpen(anyCollapsed()); }); bar.appendChild(allBtn); firstAcc.parentNode.insertBefore(bar, firstAcc); } var tech = document.getElementById('techChips'); if (tech) tech.addEventListener('click', function (e) { if (!e.target.closest('.chip-card')) return; renderMaterialCards(); resetDownstream('tech'); // changing tech resets material+below — re-reveal so they auto-open again setOpen('tech', false); // Technology auto-closes once chosen; everything below reveals + stays open updateAccordionSummaries(); refreshAccordion(); }); var color = document.getElementById('colorPicker'); if (color) color.addEventListener('click', function (e) { var sw = e.target.closest('.color-swatch'); if (!sw) return; updateAccordionSummaries(); // color does not auto-advance — stays open until you close it }); var infill = document.getElementById('infillDensityChips'); if (infill) infill.addEventListener('click', function (e) { if (!e.target.closest('.chip-card')) return; updateAccordionSummaries(); advance('infill'); }); renderMaterialCards(); refreshAccordion(); } try { window.__collapseAccordion = function () { setAllOpen(false); }; } catch (e) {} if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initConfigAccordion); else initConfigAccordion(); })(); // ── Locked-quote preload ────────────────────────────────────────── // /?quote=Q-15500-1&t= boots the quoter as the ORDER page for a staff- // issued quote: models load into the viewer, configs apply, prices pin to the // issued numbers, and checkout runs the normal steps. The server enforces the // same lock in priceCart, so everything here is presentation — tampering with // any of it changes nothing about what is charged. // Now, on the shop's clock. DST-proof via Intl; string compare works on ISO days. function _lqEtNow() { var p = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date()); var g = {}; p.forEach(function (x) { g[x.type] = x.value; }); return { day: g.year + '-' + g.month + '-' + g.day, min: Number(g.hour) * 60 + Number(g.minute) }; } // A committed date holds until 3:30 PM ET on its order-by day — the same // production cutoff the rest of the quoter lives by. 'Order by Aug 1' means // 'in time to start production Aug 1'. function _lqCommittedDate() { var lq = window.__lockedQuote; var iss = lq && lq.issued; if (!iss || iss.leadTimeMode !== 'committed_date' || !iss.readyDate) return null; var holds = true, today = false; if (iss.orderByDate) { try { var et = _lqEtNow(); today = et.day === iss.orderByDate; holds = et.day < iss.orderByDate || (today && et.min < (15 * 60 + 30)); } catch (e) {} } return { readyDate: iss.readyDate, orderByDate: iss.orderByDate || null, holds: holds, today: today }; } function _lqFmtDay(iso) { try { return new Date(iso + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } catch (e) { return iso; } } // A SAVED OR SENT QUOTE'S FILES COME BACK BY THE SAME TWO ROUTES AN UPLOAD // GOES OUT BY. // // Both boots below -- _resumeBoot (a customer reopening a cart they saved) // and _lqBoot (a quote we sent them) -- fetched each part's signed link // straight from our storage host. A network that refuses that host // (Huhtamaki and Fresh, 2026-09) refused every one, the failure was counted // and swallowed, and the customer read "1 part couldn't be reloaded -- // re-upload to include them": the one thing that same network refuses too. // uploadPut (public/upload-put.mjs) sends the GET exactly as before, and only // when the network REFUSES it sends the same GET through our own origin, as // mlqReQuote already does for a reorder. // // ONE function for both boots, so neither can drift back to a bare fetch on // its own. It sets sp._fname on every part and sp._file on each that // arrived, exactly as the two inline loops did, and returns unitByFile and // missing as they had them -- plus which parts did not arrive (lost) and, // from the first failure that could tell, what the network did (verdict: // 'blocked', 'offline' or null). Anything missing is told to us as // quote_resume_load_failed, with `where` saying which boot: its own name // rather than reorder_load_failed, so a count of failed reorders stays a // count of reorders. async function _lqFetchStoredFiles(stored, where, quoteNo) { var got = { unitByFile: {}, missing: 0, lost: [], verdict: null }; var miss = function (sp, why) { got.missing++; got.lost.push(sp._fname); if (!got.verdict && why) got.verdict = uploadPut.verdictOf(why); }; await Promise.all(stored.map(function (sp) { sp._fname = sp.fileName || sp.name; if (!sp.fileUrl) { miss(sp, null); return Promise.resolve(); } return uploadPut(sp.fileUrl, { method: 'GET' }, { name: sp._fname, kind: 'download' }).then(function (fr) { if (!fr || !fr.ok) { miss(sp, fr); return; } return fr.arrayBuffer().then(function (buf) { if (!buf || !buf.byteLength) { miss(sp, null); return; } sp._file = new File([buf], sp._fname); got.unitByFile[sp._fname] = sp.displayUnit || 'mm'; }); }).catch(function (e) { miss(sp, e); }); })); if (got.missing) { try { qEvent('quote_resume_load_failed', { metadata: { where: where, quote_no: quoteNo || null, lost: got.lost.slice(0, 20), of: stored.length, verdict: got.verdict } }); } catch (e) {} } return got; } // /?resume=Q-… reopens a cart the signed-in customer saved themselves. Same // preload as a staff quote — files in, configs applied — and then deliberately // NOT locked: no __lockedQuote, so parts stay editable and the cart prices at // today's numbers. A saved cart is a cart, not a promise. async function _resumeBoot() { var qs = new URLSearchParams(location.search); var no = qs.get('resume'); if (!no) return; // Remembered for checkout: if this cart was handed to me by someone else to // pay (lib/quote-share.mjs), place-order CCs them on the order. try { window.__resumedQuoteNo = no; } catch (e) {} try { setLoading(true, 'Reopening ' + no + '…'); var r = await fetch('/api/quote-resume?no=' + encodeURIComponent(no), { credentials: 'include' }); var j = await r.json().catch(function () { return {}; }); if (!r.ok || !j || j.state !== 'ok') { setLoading(false); var _next = _acceptNextStep(j && j.state); // NOBODY IS SIGNED IN, WHICH IS NOT AN ERROR. // // 6e1ff2f added the view gate on 2026-08-25 and this page was never // taught its sign_in_required state, so a signed-out customer fell // through to the generic branch below -- which clears the URL and says // "Could not reopen that quote." The ?no= and &t= in that URL are the // customer's only credential for the quote, so clearing them meant a // refresh could not recover it either. What they saw was the quoter's // empty "drop your files here" state: an empty page, on a quote they // were asked to pay. cxie@fresh.com hit it on Q-15985-1, 2026-09-24. // // The URL is left alone HERE on purpose: signing in has to come back to // this quote, and the reload below is what re-runs this load with a // session attached. if (_next === 'sign_in') { if (typeof window !== 'undefined' && window.mlqAuth && window.mlqAuth.require) { try { await window.mlqAuth.require(); location.reload(); return; } catch (e) {} } if (typeof showQuoterToast === 'function') { showQuoterToast('Sign in with the address this quote was sent to, and it will open.'); } return; } // /account/orders/ routes on the production number; an ML- reference // lands on nothing. Send them to the list instead of a dead page. if (j && j.state === 'ordered') { var _no = String(j.orderRef || ''); location.replace(/^[0-9]+$/.test(_no) ? '/account/orders/' + encodeURIComponent(_no) : '/account/orders'); return; } try { history.replaceState({}, '', '/'); } catch (e) {} if (typeof showQuoterToast === 'function') { var _st = (j && j.state) || ''; showQuoterToast( _st === 'not_yours' ? ((j && j.sentTo && j.sentTo.length) ? ('This quote was sent to ' + j.sentTo.join(' and ') + (j.signedInAs ? ', but you are signed in as ' + j.signedInAs : '') + '. Sign in with that address to open and pay it.') : 'That quote belongs to another account.') : _st === 'not_found' ? "That quote number no longer exists." : (j && j.error) ? 'Could not reopen that quote: ' + j.error : 'Could not reopen that quote.'); } return; } var stored = j.quote.parts || []; var got = await _lqFetchStoredFiles(stored, 'resume', j.quote.quoteNo || no); var unitByFile = got.unitByFile, missing = got.missing; var files = stored.filter(function (sp) { return sp._file; }).map(function (sp) { return sp._file; }); window.__lqPreload = { unitByFile: unitByFile }; try { if (files.length) await handleFiles(files); } finally { window.__lqPreload = null; } // Same one-to-one pairing the locked boot uses: duplicated parts share a // name, so the queue keeps them matched in creation order. var queue = {}; stored.forEach(function (sp) { if (sp._file) (queue[sp._fname] = queue[sp._fname] || []).push(sp); }); parts.forEach(function (p) { var q2 = queue[p.name]; var sp = q2 && q2.shift(); if (!sp) return; p.tech = sp.tech; p.material = sp.material; p.color = sp.color; // The typed custom colour comes back with the cart too, or a resumed // quote checks out as a bare "custom" with the match thrown away. p.colorCustom = sp.colorCustom || null; // AND THE TYPED MATERIAL, for the identical reason. colorCustom was fixed // here and materialCustom was missed in the same loop, though the server // sends both and two places read it back off the part (the material name // in the cart, and the name that reaches the floor row). A customer who // asked for a specific material by name reopened their quote and saw the // generic group name instead -- their words dropped without a word. p.materialCustom = sp.materialCustom || null; p.qty = Math.max(1, Number(sp.qty) || 1); if (sp.quality != null) p.resolution = sp.quality; if (sp.infillDensity != null) p.infillDensity = sp.infillDensity; if (sp.infillMult != null) p.infillMult = sp.infillMult; p.finishing = sp.finishing || null; // AND WHAT THEY MARKED ON THE MODEL. Same reasoning as colorCustom // above, and the same failure: the customer dropped annotation pins, // painted cosmetic faces and chose a face to sit on the build plate, we // stored all of it on the quote (073e2cd), and rebuilding the part here // left every one of them behind -- so the order placed from a resumed or // a shared quote wrote nulls to the floor row. They did the work, we kept // it, and we dropped it on the way back. // // BOTH BOOT PATHS, deliberately. _resumeBoot (a customer reopening their // own saved cart) and _lqBoot (a staff-issued or shared quote) run the // same rebuild and both lost it. api/quote-accept.mjs cartParts is the // projection that feeds them and had to be widened to send the three in // the first place. // // THE VIEWER DOES NOT REDRAW THEM. The pins are not re-pinned and the // painted faces are not re-painted -- that means rebuilding 3D state a // rebuilt mesh does not have, and it is a real remaining gap rather than // something this hides. What is fixed here is the DATA: _ckPartExtras // falls back to these when the live tools have nothing to say, so the // markup reaches the floor row again. p.notes = sp.notes || null; p.annotations = Array.isArray(sp.annotations) && sp.annotations.length ? sp.annotations : null; p.cosmetic = sp.cosmetic || null; p.orientation = sp.orientation || null; p.displayUnit = sp.displayUnit || p.displayUnit; // CARRY THE UPLOAD ID. // // This loop rebuilds a part from a stored quote, and it copied every // field the customer had chosen and none of the identity. So a resumed // or locked quote checked out with uploadId null on EVERY part, the // landing could not tell which file each part was, and the order was // recorded 2 of 2 -- or 5 of 5 -- with no file link. // // Order 11573, 2026-08-14: both upload rows existed, with thumbnails, // 36 minutes before checkout. Nothing was missing except this line. // // /api/quote-resume already sends it: quote-resume.mjs:138 reads // p.uploadId to sign the file, and flags 'no_upload_id' when it is // absent. The id was in the payload the whole time. if (sp.uploadId) p.uploadId = sp.uploadId; // THE SCALE THE CUSTOMER CHOSE, in either shape it was stored in. This // file writes scaleVec as { x, y, z } and the staff editor writes // [x, y, z]; the guard here was Array.isArray, which accepted only the // second, so a customer's own scaled part was rebuilt at 1x. Written out // inline rather than calling a helper because these loops are lifted out // of the file and run on their own by the resume tests, so a name defined // elsewhere in the page is not in scope. var _rsv = sp.scaleVec; if (_rsv && typeof _rsv === 'object') { var _ra = Array.isArray(_rsv); var _rx = Number(_ra ? _rsv[0] : _rsv.x), _ry = Number(_ra ? _rsv[1] : _rsv.y), _rz = Number(_ra ? _rsv[2] : _rsv.z); if (isFinite(_rx) && isFinite(_ry) && isFinite(_rz)) p.scaleVec = { x: _rx, y: _ry, z: _rz }; } try { applyPartScale(p); } catch (e) {} // THE VOLUME HAS TO BE RE-DERIVED, BECAUSE BOTH ITS INPUTS JUST CHANGED. // // The two lines above restore displayUnit and scaleVec from the stored // quote. volumeCm3 and surfaceCm2 are NOT restored -- they still hold // what the freshly loaded mesh produced under the FILE's own unit at // scale 1 -- and nothing recomputed them. So a part saved in inches came // back priced in millimetres: the same digits, 16,387 times too small. // // The server does not make this mistake. lib/stored-geometry.mjs derives // the volume from displayUnit and scaleVec every time, so the SERVER // price was right and the SCREEN price was wrong -- which is the one // direction that reaches a customer as "you charged me more than you // showed me". The total-mismatch refusal a few thousand lines up is what // caught it, and it reported through a function nobody had defined, so it // has been catching it in silence (58223c9). // // Measured 2026-09-10: four customers in thirty days sent a checkout // payload whose volumeCm3 disagreed with its own displayUnit. // erickcandelero@gmail.com is the extreme -- displayUnit 'in' against a // millimetre volume on all nine attempts, screen $16.33, PaymentIntent // $749.41. // // recomputeDerivedStats is the SAME derivation the unit toggle uses, so // restoring a quote and changing a unit by hand now land on one answer. try { recomputeDerivedStats(p); } catch (e) {} var snap = {}; _APPLY_KEYS.forEach(function (k) { snap[k] = p[k]; }); p._applied = snap; }); if (j.quote.tier) { orderDeliveryTier = j.quote.tier; ckLeadChosen = true; } if (j.quote.dispatch) { orderDispatch = j.quote.dispatch; ckDispatchChosen = true; } try { history.replaceState({}, '', '/'); } catch (e) {} try { renderPartsList(); } catch (e) {} try { renderSelectedPart(); } catch (e) {} try { updatePartPrice(parts); } catch (e) {} try { updateGrandTotal(); } catch (e) {} try { updateApplyButton(); } catch (e) {} setLoading(false); if (missing && typeof showQuoterToast === 'function') { var _lostSaid = missing + ' part' + (missing === 1 ? '' : 's') + " couldn't be reloaded"; // Refused on both routes: re-uploading cannot work from that network // either, so it gets the one instruction that does -- the words the // upload, the payment gate and the reorder already give. if (got.verdict === 'blocked') showQuoterToast(_lostSaid + '. ' + blockedAdvice(missing > 1), 12000); else showQuoterToast(_lostSaid + ' — re-upload to include them.'); } } catch (e) { setLoading(false); try { console.error('[resume] failed', e); } catch (e2) {} try { history.replaceState({}, '', '/'); } catch (e2) {} } } async function _lqBoot() { var qs = new URLSearchParams(location.search); var no = qs.get('quote'), tok = qs.get('t'); if (!no || !tok) return; var acceptHref = '/accept?no=' + encodeURIComponent(no) + '&t=' + encodeURIComponent(tok); try { setLoading(true, 'Loading quote ' + no + '…'); var r = await fetch('/api/quote-accept?cart=1&no=' + encodeURIComponent(no) + '&t=' + encodeURIComponent(tok)); var j = await r.json(); if (!j || j.state !== 'ok') { location.replace(acceptHref); return; } var Q = j.quote; var stored = Q.parts || []; // Through the same helper as _resumeBoot, but with no advice added. This // quote is priced and locked: it can be paid with a part that will not // preview (the banner says how many), and with none at all it hands back // to /accept, which takes payment without models. Telling them to email // us a file we already hold, for a price already set, would be an errand // for nothing. var got = await _lqFetchStoredFiles(stored, 'locked_quote', Q.quoteNo || no); var unitByFile = got.unitByFile, missing = got.missing; var files = stored.filter(function (sp) { return sp._file; }).map(function (sp) { return sp._file; }); // Not one model reached the browser. This cart can show nothing, price // nothing and -- because the quote is locked -- accept no replacement // files, so it is a dead end. Hand back to /accept, which renders the // summary and takes payment without models. The flag is what stops it // bouncing straight back here. if (_lqShouldBailToAccept(stored.length, files.length)) { location.replace(acceptHref + '&nofiles=1'); return; } window.__lqPreload = { unitByFile: unitByFile }; try { if (files.length) await handleFiles(files); } finally { window.__lqPreload = null; } // Match created parts to stored ones — same file name, in creation order // (duplicated parts share a name; the queue keeps them paired one-to-one). var queue = {}; stored.forEach(function (sp) { if (sp._file) (queue[sp._fname] = queue[sp._fname] || []).push(sp); }); var byPart = {}; parts.forEach(function (p) { var q2 = queue[p.name]; var sp = q2 && q2.shift(); if (!sp) return; p.tech = sp.tech; p.material = sp.material; p.color = sp.color; // The typed custom colour comes back with the cart too, or a resumed // quote checks out as a bare "custom" with the match thrown away. p.colorCustom = sp.colorCustom || null; // AND THE TYPED MATERIAL, for the identical reason. colorCustom was fixed // here and materialCustom was missed in the same loop, though the server // sends both and two places read it back off the part (the material name // in the cart, and the name that reaches the floor row). A customer who // asked for a specific material by name reopened their quote and saw the // generic group name instead -- their words dropped without a word. p.materialCustom = sp.materialCustom || null; p.qty = Math.max(1, Number(sp.qty) || 1); if (sp.quality != null) p.resolution = sp.quality; if (sp.infillDensity != null) p.infillDensity = sp.infillDensity; if (sp.infillMult != null) p.infillMult = sp.infillMult; p.finishing = sp.finishing || null; // AND WHAT THEY MARKED ON THE MODEL. Same reasoning as colorCustom // above, and the same failure: the customer dropped annotation pins, // painted cosmetic faces and chose a face to sit on the build plate, we // stored all of it on the quote (073e2cd), and rebuilding the part here // left every one of them behind -- so the order placed from a resumed or // a shared quote wrote nulls to the floor row. They did the work, we kept // it, and we dropped it on the way back. // // BOTH BOOT PATHS, deliberately. _resumeBoot (a customer reopening their // own saved cart) and _lqBoot (a staff-issued or shared quote) run the // same rebuild and both lost it. api/quote-accept.mjs cartParts is the // projection that feeds them and had to be widened to send the three in // the first place. // // THE VIEWER DOES NOT REDRAW THEM. The pins are not re-pinned and the // painted faces are not re-painted -- that means rebuilding 3D state a // rebuilt mesh does not have, and it is a real remaining gap rather than // something this hides. What is fixed here is the DATA: _ckPartExtras // falls back to these when the live tools have nothing to say, so the // markup reaches the floor row again. p.notes = sp.notes || null; p.annotations = Array.isArray(sp.annotations) && sp.annotations.length ? sp.annotations : null; p.cosmetic = sp.cosmetic || null; p.orientation = sp.orientation || null; p.displayUnit = sp.displayUnit || p.displayUnit; // CARRY THE UPLOAD ID. // // This loop rebuilds a part from a stored quote, and it copied every // field the customer had chosen and none of the identity. So a resumed // or locked quote checked out with uploadId null on EVERY part, the // landing could not tell which file each part was, and the order was // recorded 2 of 2 -- or 5 of 5 -- with no file link. // // Order 11573, 2026-08-14: both upload rows existed, with thumbnails, // 36 minutes before checkout. Nothing was missing except this line. // // /api/quote-resume already sends it: quote-resume.mjs:138 reads // p.uploadId to sign the file, and flags 'no_upload_id' when it is // absent. The id was in the payload the whole time. if (sp.uploadId) p.uploadId = sp.uploadId; // THE SCALE THE CUSTOMER CHOSE, in either shape it was stored in. This // file writes scaleVec as { x, y, z } and the staff editor writes // [x, y, z]; the guard here was Array.isArray, which accepted only the // second, so a customer's own scaled part was rebuilt at 1x. Written out // inline rather than calling a helper because these loops are lifted out // of the file and run on their own by the resume tests, so a name defined // elsewhere in the page is not in scope. var _rsv = sp.scaleVec; if (_rsv && typeof _rsv === 'object') { var _ra = Array.isArray(_rsv); var _rx = Number(_ra ? _rsv[0] : _rsv.x), _ry = Number(_ra ? _rsv[1] : _rsv.y), _rz = Number(_ra ? _rsv[2] : _rsv.z); if (isFinite(_rx) && isFinite(_ry) && isFinite(_rz)) p.scaleVec = { x: _rx, y: _ry, z: _rz }; } try { applyPartScale(p); } catch (e) {} // THE VOLUME HAS TO BE RE-DERIVED, BECAUSE BOTH ITS INPUTS JUST CHANGED. // // The two lines above restore displayUnit and scaleVec from the stored // quote. volumeCm3 and surfaceCm2 are NOT restored -- they still hold // what the freshly loaded mesh produced under the FILE's own unit at // scale 1 -- and nothing recomputed them. So a part saved in inches came // back priced in millimetres: the same digits, 16,387 times too small. // // The server does not make this mistake. lib/stored-geometry.mjs derives // the volume from displayUnit and scaleVec every time, so the SERVER // price was right and the SCREEN price was wrong -- which is the one // direction that reaches a customer as "you charged me more than you // showed me". The total-mismatch refusal a few thousand lines up is what // caught it, and it reported through a function nobody had defined, so it // has been catching it in silence (58223c9). // // Measured 2026-09-10: four customers in thirty days sent a checkout // payload whose volumeCm3 disagreed with its own displayUnit. // erickcandelero@gmail.com is the extreme -- displayUnit 'in' against a // millimetre volume on all nine attempts, screen $16.33, PaymentIntent // $749.41. // // recomputeDerivedStats is the SAME derivation the unit toggle uses, so // restoring a quote and changing a unit by hand now land on one answer. try { recomputeDerivedStats(p); } catch (e) {} var snap = {}; _APPLY_KEYS.forEach(function (k) { snap[k] = p[k]; }); p._applied = snap; byPart[p.id] = { total: sp.total, qty: p.qty }; }); window.__lockedQuote = { no: no, token: tok, quoteNo: Q.quoteNo, price: Number(Q.price) || 0, tier: Q.tier || 'standard', byPart: byPart, expiresAt: Q.expiresAt || null, issued: Q.issued || null, taxExempt: !!Q.taxExempt, fixedFee: Number(Q.fixedFee) || 0 }; // A quote sent to an exempt company is exempt for whoever opens it. // // _ckTaxExempt was set ONLY by /api/credit-balance, which needs a session. // Someone quoted by email who has never signed in gets a 401 there, so the // flag stayed false and their own quote showed 8.878% tax. The quote now // carries the answer, resolved server-side from the company it was sent to // (Christina, 2026-08-10). // // Only ever turns exemption ON: credit-balance may still set it for a // signed-in customer, and the two can never disagree in the direction that // shows a total we would then exceed on the card. if (Q.taxExempt) { _ckTaxExempt = true; try { renderCheckoutSummary(); } catch (e) {} } // A staff-issued quote can only be bought by the address it was sent to, so // the sign-in overlay prefills and locks that address. Without this a // colleague opening the forwarded link signs in as themselves, gets a code, // and only then hits the server's refusal. (Christina 2026-08-01) try { if (Q.email) window.mlqLockEmail = Q.email; } catch (e) {} orderDeliveryTier = window.__lockedQuote.tier; ckLeadChosen = true; document.body.classList.add('lq-locked'); _lqInstallChrome(Q, missing, acceptHref); try { renderPartsList(); } catch (e) {} try { renderSelectedPart(); } catch (e) {} try { updatePartPrice(parts); } catch (e) {} try { updateGrandTotal(); } catch (e) {} try { updateApplyButton(); } catch (e) {} setLoading(false); try { if (typeof qEvent === 'function') qEvent('quote_cart_opened', { metadata: { quote_no: Q.quoteNo }, price: window.__lockedQuote.price }); } catch (e) {} } catch (e) { setLoading(false); try { console.error('[locked-quote] preload failed', e); } catch (e2) {} location.replace(acceptHref); } } function _lqInstallChrome(Q, missing, acceptHref) { var css = '' + 'body.lq-locked #dropZone{display:none!important}' + 'body.lq-locked #addPartBtn,body.lq-locked .part-row-remove,body.lq-locked .part-qty-arrows{display:none!important}' + 'body.lq-locked .part-row-qty{pointer-events:none!important}' + 'body.lq-locked #applyQuoteBtn{display:none!important}' + 'body.lq-locked .cfg-acc input,body.lq-locked .cfg-acc select,body.lq-locked .cfg-acc button,body.lq-locked .cfg-acc label{pointer-events:none!important;opacity:.75}' + '#lqBanner{background:#252e44;color:#fff;border-radius:12px;padding:10px 14px;margin-bottom:12px;font-size:.78rem;line-height:1.5;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}' + '#lqBanner b{font-weight:600}#lqBanner .lq-sub{color:#b9c0d4;margin-top:2px}#lqBanner a{color:#ffcc00;text-decoration:underline;cursor:pointer}'; var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); var el = document.createElement('div'); el.id = 'lqBanner'; var valid = null; try { valid = Q.expiresAt ? new Date(Q.expiresAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : null; } catch (e) {} var _commit = ''; try { var iss = Q.issued || {}; if (iss.leadTimeMode === 'committed_date' && iss.readyDate) { _commit = 'Ready by ' + _lqFmtDay(iss.readyDate) + (iss.orderByDate ? ' · order by ' + _lqFmtDay(iss.orderByDate) + ', 3:30 PM ET' : '') + ' · '; } else if (iss.leadTimeMode === 'committed_leadtime' && iss.leadTier) { var _tn = (typeof DELIVERY_TIERS !== 'undefined' && (DELIVERY_TIERS.find(function (t) { return t.key === iss.leadTier; }) || {}).name) || iss.leadTier; _commit = 'Committed speed: ' + _tn + ' · '; } } catch (e) {} el.innerHTML = '
    Quote ' + String(Q.quoteNo || '').replace(/[<>&]/g, '') + ' — price locked' + (missing ? ' · ' + missing + ' part' + (missing === 1 ? '' : 's') + ' priced but not previewable' : '') + '
    ' + '
    ' + _commit + (valid ? 'Valid until ' + valid + ' · ' : '') + 'Decline this quote
    '; var host = document.getElementById('sidebarFooter'); if (host) host.insertBefore(el, host.firstChild); else document.body.appendChild(el); var d = document.getElementById('lqDecline'); if (d) d.addEventListener('click', function () { if (!confirm('Decline quote ' + Q.quoteNo + '? We\'ll close it and let the team know.')) return; fetch('/api/quote-accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ no: window.__lockedQuote.no, t: window.__lockedQuote.token, action: 'decline' }) }) .then(function () { location.replace(acceptHref); }) .catch(function () { location.replace(acceptHref); }); }); } // ── DESIGN REVIEW: THE VIEWER IS THE REVIEW ───────────────────────────────── // // Christina, 2026-09-12: "id rather the review thing just have the instant // quoter viewer with the tool annotate automatically opened and active. and // then the screen could guide them through what to do and there is an approve // and comment/reject button." // // So this is not a page that shows a design -- it is this viewer, opened on the // design, with the pin tool already live. Every pin the client drops is already // exactly what design_comments.anchor was built to hold: a point in MODEL // space, which survives them rotating the part (migration 185). var _drToken = null, _drData = null; // READING THEIR NOTES, rather than answering ours. // // The design engineer needs to see the pins the client dropped ON THE MODEL, // where they were pointing -- the whole point of them pinning rather than // typing. Without this the loop is half closed: they mark six places and we // read six rows of coordinates out of a database. // // Same page, same token, one flag: ?notes=1. It shows BOTH sides' pins and // takes the answering away -- no approve, no send, no pin tool. Nothing is // revealed that the token did not already carry (the comments come back with // every review either way); what changes is which of them get drawn. var _drNotesOnly = false; var _drMissing = []; // OUR PINS, ON THEIR MODEL. // // Christina, 2026-09-12, on the first cut's chat log: "dont love this. // instead when we upload on our end, there should be a [spot] to enter some // feedback or even 3d annotate ourselves to communicate with the client." // // Right: a design conversation is not a transcript. It is a revision, what we // changed in it, and where -- so a note from us that points at a place belongs // ON the model, next to the thing it is about, exactly as their own pins do. // // Deliberately NOT pushed into `annotations`. That array is what the client is // building and what _drPins() sends back; putting ours in it would mean our own // words returning to us as their feedback, and would let them edit or delete a // note we wrote. These are drawn and positioned alongside, and are read-only. var _drStaffPins = []; function _drClearStaffPins() { _drStaffPins.forEach(function (x) { if (x.el) x.el.remove(); }); _drStaffPins.length = 0; } function _drPlaceStaffPins() { _drClearStaffPins(); // Ours always. Theirs too, when we are the ones reading. // // AND ONLY THIS REVISION'S. A pin placed on an earlier model floats in space // beside the current one, pointing confidently at a place nobody chose -- // which is what migration 185's revision column exists to prevent. NULL is // "the revision being prepared or just sent", so it counts as current. // null means "nothing stamped", which is NOT revision 0. This seeded the // reduce at 0 and ended `|| 1`, so a line whose pins were all on revision 0 -- // the first revision a line gets -- read as revision 1 and every pin on it // was filtered out. Part 90200-2's two staff notes were in the payload and // drew nothing. Number(null) is 0, so the skip is by identity, not by value. var _drRev = ((_drData && _drData.comments) || []).reduce(function (m, c) { var raw = (c === null || c === undefined) ? null : c.revision; if (raw === null || raw === undefined) return m; var n = Number(raw); return (isFinite(n) && (m === null || n > m)) ? n : m; }, null); if (_drRev === null) _drRev = 1; var list = ((_drData && _drData.comments) || []).filter(function (c) { if (!c.anchor) return false; if (!(_drNotesOnly || c.from !== 'client')) return false; var n = (c.revision === null || c.revision === undefined) ? null : Number(c.revision); return n === null || n === _drRev; }); if (!list.length || !parts.length) return; list.forEach(function (c, i) { var pin = { num: i + 1, note: c.body || '', // Model space, which is where it was recorded -- so it stays on the spot // it marks however the client turns the part. localPt: new THREE.Vector3(Number(c.anchor.x) || 0, Number(c.anchor.y) || 0, Number(c.anchor.z) || 0), // WHICH MODEL IT IS ON. anchor.file names it (jsonb, so the point and // the model it is a point on stay one fact). With one model, or a pin // from before there could be more than one, it is the only part there is. part: _drPartFor(c.anchor && c.anchor.file), el: document.createElement('div'), }; var theirs = c.from === 'client'; pin.el.className = 'dr-staffpin' + (theirs ? ' theirs' : ''); pin.el.innerHTML = '
    ' + pin.num + '
    ' + '
    ' + _escHtml(theirs ? (c.name || 'The client') : 'Makelab') + '' + _escHtml(pin.note) + '
    '; if (!pin.part) return; document.body.appendChild(pin.el); _drStaffPins.push(pin); _drPosStaffPin(pin); }); } // The loaded part a pin belongs to, by the file name it was dropped on. function _drPartFor(fileName) { if (!parts.length) return null; if (!fileName) return parts[0]; var want = String(fileName).toLowerCase(); for (var i = 0; i < parts.length; i++) { if (String(parts[i].name || '').toLowerCase() === want) return parts[i]; } // Named a model that is not in this revision -- an older pin, or a file we // replaced. Drawing it on whichever part happens to be first would put it in // a place nobody chose, so it is not drawn at all. return null; } function _drPosStaffPin(pin) { if (!pin.el) return; var part = pin.part; if (!part || !part.mesh || part.mesh.visible === false || part.hidden) { pin.el.style.display = 'none'; return; } part.mesh.updateMatrixWorld(true); var world = part.mesh.localToWorld(pin.localPt.clone()); if (!world || (typeof _isSectionClippedAway === 'function' && _isSectionClippedAway(world))) { pin.el.style.display = 'none'; return; } var sc = worldToScreen(world); if (typeof _isOutsideCanvas === 'function' && _isOutsideCanvas(sc)) { pin.el.style.display = 'none'; return; } pin.el.style.display = ''; pin.el.style.left = sc.x + 'px'; pin.el.style.top = sc.y + 'px'; } async function _drBoot() { var qs = new URLSearchParams(location.search); _drToken = qs.get('design'); if (!_drToken) return; _drNotesOnly = qs.get('notes') === '1'; try { setLoading(true, 'Loading your design\u2026'); // TELL THE ROUTE WHOSE VIEW THIS IS. // // ?notes=1 is OUR read of the client's pins, on the same link and the same // route, and the server records an open from this GET. Without this flag a // staff read would be filed as "the client opened it", leaving somebody // waiting on a reply from a person who never saw the design. var r = await fetch('/api/design-review?t=' + encodeURIComponent(_drToken) + (_drNotesOnly ? '¬es=1' : '')); if (!r.ok) { setLoading(false); _drDead(); return; } var j = await r.json(); _drData = j; // EVERY FILE IN THE REVISION. A design is handed over as a set -- the // editable STEP, the printable STL, sometimes more -- and the viewer is // natively a multi-part scene, so they open together the way a cart does. var models = (j.models && j.models.length) ? j.models : (j.model ? [j.model] : []); models = models.filter(function (m) { return m && m.url; }); if (!models.length) { setLoading(false); _drDead('There is nothing up for review on this design yet. We will email you the moment there is.'); return; } var unitByFile = {}, loaded = []; for (var mi = 0; mi < models.length; mi++) { var m = models[mi]; var fr = await fetch(m.url); // ONE THAT WILL NOT LOAD DOES NOT TAKE THE OTHERS WITH IT: a client // looking at two of three models can still say what they think about // those two, and the rail says which one is missing. if (!fr.ok) { _drMissing.push(m.fileName || 'a file'); continue; } var buf = await fr.arrayBuffer(); var f = new File([buf], m.fileName || ('design-' + (mi + 1) + '.stl')); unitByFile[f.name] = m.unit || 'mm'; loaded.push(f); } if (!loaded.length) { setLoading(false); _drDead('We could not load your design. Refresh, or call us and we will sort it out.'); return; } // The same preload seam a restored quote uses, so each model arrives in the // unit it was made in rather than being guessed at. window.__lqPreload = { unitByFile: unitByFile }; try { await handleFiles(loaded); } finally { window.__lqPreload = null; } // THE POINT OF DOING IT IN HERE: the pin tool is already on, so the first // thing a click does is mark the thing they are talking about. // The pin tool goes on, but NOT with its usual notice: that one ends // "pins go to our team with your quote", and this person has no quote. // Marking it as already shown is what stops setTool popping it. if (!_drNotesOnly) { try { var _tn = document.getElementById('toolNotice'); if (_tn) _tn.dataset.shownFor = 'highlight'; setTool('highlight'); showToolNotice('designReview'); } catch (e) {} } // Our own notes, on the spots they are about. try { _drPlaceStaffPins(); } catch (e) {} _drMountRail(); } catch (e) { setLoading(false); _drDead(); } } function _drDead(msg) { var el = document.createElement('div'); el.className = 'dr-dead'; el.textContent = msg || 'This design link is not valid. It may have been replaced by a newer one.'; document.body.appendChild(el); } // What a client is being asked to do, said once, where they are doing it. // The revisions this line has seen, newest first. The one in front of them is // marked; the rest are how it got here. Hidden until there are two, because a // "history" of one entry is noise. function _drHistoryHtml(d) { var revs = (d && d.revisions) || []; if (revs.length < 2) return ''; var items = revs.map(function (r) { var when = ''; try { when = new Date(r.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch (e) { when = ''; } var now = r.number === d.revision; return '' + '' + _escHtml(String(r.number)) + '' + '' + _escHtml(r.name || ('Revision ' + r.number)) + '' + (when ? '' + _escHtml(when) + '' : '') + (now ? 'You are looking at this one' : '') + ''; }).join(''); return '
    Revision history
      ' + items + '
    '; } function _drMountRail() { if (document.getElementById('drRail')) return; _drApplyApproved(); var d = _drData || {}; if (_drNotesOnly) { _drMountNotesRail(d); return; } var approved = !d.canApprove && d.approvedAt; // Sent, but not approved: their words are with us and we owe them a // revision. Saying 'approved' here would be a different promise entirely. var sent = !approved && d.canComment === false; var rail = document.createElement('div'); rail.id = 'drRail'; rail.className = 'dr-rail'; rail.innerHTML = '
    ' + '
    Design review \u00b7 ' + _escHtml(d.orderRef || '') + '
    ' + // WHICH REVISION THEY ARE LOOKING AT, by name. // // Every revision shares one URL -- the link is the line's token -- so an // older email opens today's model. Nothing on this page said which one it // was, while the email subject said "Revision 3", so a client could approve // something they had never been told about. Christina, 2026-09-13: // "revisions need to be named. revisions are controlled by us." (d.revision ? '
    Revision ' + _escHtml(String(d.revision)) + (d.revisionName && d.revisionName !== ('Revision ' + d.revision) ? ' \u00b7 ' + _escHtml(d.revisionName) : '') + '
    ' : '') + '
    ' + (approved ? 'You approved this design' : (sent ? 'Your notes are with us' : 'Tell us what you think')) + '
    ' + '
    ' + ((approved || sent) ? '' : '
      ' + '
    1. Turn it around. Drag to rotate, scroll to zoom, right-drag to slide it.
    2. ' + '
    3. Click the model where something should change. A numbered pin drops there \u2014 type what you want and press Done.
    4. ' + '
    5. Measure in the toolbar gives you a distance between two clicks, if you need one.
    6. ' + '
    7. Then approve, or send your notes.
    8. ' + '
    ' + '') + (d.scopeOfWork ? '
    What we agreed to make

    ' + _escHtml(d.scopeOfWork) + '

    ' : '') + '
    ' + // THE HISTORY. Christina, 2026-09-13: "the instant quote viewer should show // a revision history." Without it a client cannot tell whether this is the // first thing we sent or the fourth, nor what we called the ones before -- // which is the whole record of the job they paid for. _drHistoryHtml(d) + (approved ? '
    \u2713 Approved by ' + _escHtml(d.approvedBy || 'you') + '
    ' // THE FILE IS WHAT THEY BOUGHT. Design work is handed over once it is // signed off, so the download appears at approval and not before. + ((d.canDownload && d.model && d.model.url) ? 'Download ' + _escHtml(d.model.fileName || 'the file') + '' : '') : sent ? '
    \u2713 We have your notes \u2014 the pins you dropped and anything you typed.' + 'We will work through them and send the next revision to this same link.
    ' : '
    ' + '' + '' + '
    ' + '' + '' + '
    ' + '
    '); document.body.appendChild(rail); _drRenderNote(); _drPinCount(); var a = document.getElementById('drApprove'); if (a) a.addEventListener('click', function () { _drSend('approve'); }); var c = document.getElementById('drChanges'); if (c) c.addEventListener('click', function () { _drSend('comment'); }); var h = document.getElementById('drHow'); // The notice dismisses on any click and remembers it has been shown, so // getting back to it means clearing that mark first. if (h) h.addEventListener('click', function (ev) { ev.stopPropagation(); try { var tn = document.getElementById('toolNotice'); if (tn) tn.dataset.shownFor = ''; showToolNotice('designReview'); } catch (e) {} }); } // WHAT WE CHANGED, NOT A TRANSCRIPT. // // Christina, 2026-09-12: "dont love this" -- of a chat log of every message // ever sent about the design. She is right. What a client needs when they open // a revision is what changed in THIS one and where; the history of how we got // here is ours, not theirs to scroll. // // The notes that point AT something are on the model as pins // (_drPlaceStaffPins). What is left is the general note that came with the // upload, which is what this shows. // ONCE IT IS APPROVED, THERE IS NOTHING LEFT TO SAY ON IT. // // Christina, 2026-09-12: "after i press approve the design i should not be // able to annotate more." // // Right, and it is not only tidiness: a pin dropped after approval would be // feedback on a design the shop has already been told to go ahead with. The // client would reasonably expect it to be read, and nothing would read it -- // the send buttons are gone by then. Better that the tool is not there. // // Three things, because turning the tool off alone is not enough: the toolbar // button would put it straight back, and the pins already on the model open // for editing when clicked. function _drApplyApproved() { var d = _drData || {}; // CLOSED EITHER WAY. Approved, or their notes are already with us -- // Christina, 2026-09-12: "sending notes should be a one time thing." It is // one exchange per revision, so once they have spoken the ball is ours and // a pin dropped now would be read by nobody. var done = (!d.canApprove && !!d.approvedAt) || d.canComment === false; try { document.documentElement.classList.toggle('dr-approved', done); } catch (e) {} if (!done) return; // setTool(null) is the viewer's own way of ending a tool -- no special case, // and it restores the ordinary cursor and mouse bindings with it. try { setTool(null); } catch (e) {} } // What the design engineer sees: their pins, in order, and nothing to press. function _drMountNotesRail(d) { var all = (d.comments || []); var theirs = all.filter(function (c) { return c.from === 'client'; }); var pins = theirs.filter(function (c) { return c.anchor; }); var notes = theirs.filter(function (c) { return !c.anchor; }); var rail = document.createElement('div'); rail.id = 'drRail'; rail.className = 'dr-rail'; rail.innerHTML = '
    ' + '
    Their notes \u00b7 ' + _escHtml(d.orderRef || '') + ' \u00b7 ' + _escHtml(d.partId || '') + '
    ' + '
    ' + (theirs.length ? 'What they asked for' : 'They have not answered yet') + '
    ' + '
    ' + (theirs.length ? '' : '

    Nothing back from them so far. This is the model they are looking at.

    ') + (pins.length ? '
    ' + (pins.length === 1 ? '1 pin' : pins.length + ' pins') + ' on the model
    ' + '
      ' + pins.map(function (c) { return '
    1. ' + _escHtml(c.body || '') + '
    2. '; }).join('') + '
    ' : '') + (notes.length ? '
    And they wrote
    ' + notes.map(function (c) { return '

    ' + _escHtml(c.body || '') + '

    '; }).join('') + '
    ' : '') + (d.approvedAt ? '
    \u2713 Approved by ' + _escHtml(d.approvedBy || 'the client') + '
    ' : ''); document.body.appendChild(rail); } function _drRenderNote() { var host = document.getElementById('drNoteFromUs'); if (!host) return; var all = (_drData && _drData.comments) || []; var fromUs = all.filter(function (c) { return c.from !== 'client' && !c.anchor; }); var latest = fromUs.length ? fromUs[fromUs.length - 1] : null; var pins = all.filter(function (c) { return c.from !== 'client' && c.anchor; }).length; var theirs = all.filter(function (c) { return c.from === 'client'; }).length; var html = ''; var models = (_drData && _drData.models) || []; if (models.length > 1) { html += '
    ' + models.length + ' files in this revision
    ' + '
      ' + models.map(function (m) { return '
    • ' + _escHtml(m.fileName || '') + '
    • '; }).join('') + '
    '; } if (_drMissing.length) { html += '
    We could not load ' + _escHtml(_drMissing.join(', ')) + '. Everything else is here \u2014 tell us and we will re-send it.
    '; } if (latest) { html += '
    What we changed

    ' + _escHtml(latest.body) + '

    '; } if (pins) { html += '
    ' + (pins === 1 ? 'We pinned 1 spot' : 'We pinned ' + pins + ' spots') + ' on the model \u2014 the numbered markers.
    '; } if (theirs) { html += '
    \u2713 ' + (theirs === 1 ? 'Your note is' : 'Your ' + theirs + ' notes are') + ' with us.
    '; } host.innerHTML = html; host.style.display = html ? '' : 'none'; } // How many pins they have dropped, so 'send my notes' is never a surprise. function _drPinCount() { var el = document.getElementById('drPinCount'); if (!el) return; var n = (typeof annotations !== 'undefined' && annotations) ? annotations.length : 0; el.textContent = n === 0 ? 'No pins yet \u2014 click the model to add one.' : (n === 1 ? '1 pin on the model' : n + ' pins on the model'); } // Every pin, in model space, with what they typed on it. A pin with nothing // written on it is still a pin: they pointed at something. function _drPins() { if (typeof annotations === 'undefined' || !annotations) return []; return annotations.map(function (a, i) { var p = a.localPt || {}; // The part it was dropped on, by name, so it comes back to the same model. var owner = parts.find(function (pp) { return pp.id === a.partId; }); return { num: i + 1, note: String(a.note || '').trim(), preset: a.presetKey || null, anchor: { x: Number(p.x) || 0, y: Number(p.y) || 0, z: Number(p.z) || 0, file: (owner && owner.name) || undefined, }, }; }); } async function _drSend(action) { var msg = document.getElementById('drMsg'); var nameEl = document.getElementById('drName'); var noteEl = document.getElementById('drNote'); var btns = [document.getElementById('drApprove'), document.getElementById('drChanges')]; var pins = _drPins(); var typed = noteEl ? String(noteEl.value || '').trim() : ''; if (action === 'comment' && !typed && !pins.length) { if (msg) msg.textContent = 'Drop a pin on the model, or write a note, and we will pick it up.'; return; } btns.forEach(function (b) { if (b) b.disabled = true; }); if (msg) msg.textContent = action === 'approve' ? 'Approving\u2026' : 'Sending\u2026'; try { var r = await fetch('/api/design-review', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ t: _drToken, action: action, name: (nameEl && nameEl.value.trim()) || undefined, body: typed || undefined, pins: pins, }), }); var j = await r.json(); if (!r.ok) { // ALREADY SENT IS NOT A FAILURE, and must not be dressed as one. // // Notes are a one-time thing per revision, so a second tab genuinely // cannot send again -- but the server used to answer that with 200 and // the full payload, so this branch never ran and the rail rendered "We // have your notes" over words that had been discarded. It answers 409 // already_sent now; what that deserves is a calm sentence, not a red // "try again" that invites them to lose the same words twice. if (r.status === 409 && j && j.error === 'already_sent') { if (msg) msg.textContent = (j.detail || 'We already have your notes for this revision.'); return; } if (msg) msg.textContent = 'That did not go through — try again.'; btns.forEach(function (b) { if (b) b.disabled = false; }); return; } _drData = j; var rail = document.getElementById('drRail'); if (rail) rail.remove(); _drMountRail(); try { _drPlaceStaffPins(); } catch (e) {} } catch (e) { if (msg) msg.textContent = 'That did not go through \u2014 try again.'; btns.forEach(function (b) { if (b) b.disabled = false; }); } } (function () { var _qs = new URLSearchParams(location.search); // ?quote= is a staff-issued quote (locked); ?resume= is your own saved cart. // ?design= is a design review: the viewer opens on the model with the pin // tool live, and the rail says what to do with it. var _boot = _qs.get('design') ? _drBoot : (_qs.get('quote') ? _lqBoot : (_qs.get('resume') ? _resumeBoot : null)); if (!_boot) return; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(_boot, 0); }); else setTimeout(_boot, 0); })();