Files
FreightCalculatorPro/index.html
T
Devon Bontrager e5ed1d3c9f Add lightweight Tauri shell, vendor offline libs, fix bugs and race conditions
- Add tauri/ subfolder: a Tauri-based desktop shell that loads the same
  index.html as the Electron app, producing a ~2MB installer (vs ~71MB for
  Electron) by using the OS WebView2 instead of bundled Chromium. Includes
  sync-frontend.js to keep tauri/dist/ in sync with the canonical index.html
  and vendor/ at build time (no UI duplication/drift).
- Vendor lucide, jsPDF, and jspdf-autotable locally under vendor/ instead of
  loading from CDNs, so the app works fully offline in both shells.
- Fix server.js path traversal vulnerability (sanitize/contain request paths).
- Fix exportPDF() using stale/edited piece data that could produce
  Infinity/NaN in generated PDFs; add a recalculate guard.
- Fix race conditions: overlapping copy/save-defaults button revert timers,
  and FileReader callbacks for logo/CSV import that could apply out of order.
- Minor cleanups: explicit array index parsing, hasResult() helper, consistent
  event binding for NMFC search results, cached DOM lookups.
- Document the dual-shell architecture and vendoring conventions in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:19:48 -04:00

1099 lines
60 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Freight Calculator Pro</title>
<!-- Vendored locally (vendor/) so the app works fully offline — see CLAUDE.md -->
<script src="vendor/lucide.js"></script>
<script src="vendor/jspdf.umd.min.js"></script>
<script src="vendor/jspdf.plugin.autotable.min.js"></script>
<style>
:root {
--primary: #2563eb; --primary-hover: #1d4ed8; --bg-color: #f1f5f9;
--card-bg: #ffffff; --text-main: #1e293b; --text-muted: #64748b;
--border-radius: 12px; --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--input-bg: #ffffff; --input-border: #e2e8f0;
--input-focus-ring: rgba(37, 99, 235, 0.1); --border-color: #e2e8f0;
--table-header-bg: #f8fafc; --table-hover: #f1f5f9;
--highlight-bg: #dbeafe; --highlight-text: #1e3a8a;
--modal-bg: rgba(0,0,0,0.5); --error-color: #ef4444;
--error-bg: rgba(239,68,68,0.08);
}
body.dark-mode {
--primary: #60a5fa; --primary-hover: #3b82f6; --bg-color: #1a1a1a;
--card-bg: #2b2b2b; --text-main: #f0f0f0; --text-muted: #a0a0a0;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5); --input-bg: #1a1a1a;
--input-border: #3d3d3d; --input-focus-ring: rgba(96,165,250,0.2);
--border-color: #3d3d3d; --table-header-bg: #1a1a1a; --table-hover: #363636;
--highlight-bg: #1e3a8a; --highlight-text: #ffffff;
--error-bg: rgba(239,68,68,0.15);
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Segoe UI', sans-serif; transition: background-color 0.3s, color 0.3s; }
body { background-color: var(--bg-color); color: var(--text-main); display: flex; justify-content: center; min-height: 100vh; padding: 20px; }
.container { width: 100%; max-width: 960px; display: flex; flex-direction: column; }
/* Layout */
.branding-header { display: flex; align-items: center; gap: 15px; margin-bottom: 10px; }
#business-logo { max-height: 60px; max-width: 200px; object-fit: contain; display: none; }
#business-name-display { font-size: 1.5rem; font-weight: 700; color: var(--primary); display: none; }
.main-title { text-align: center; margin-bottom: 24px; }
h1 { font-size: 1.8rem; margin-bottom: 4px; }
.content-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
/* Top controls */
.top-controls { position: absolute; top: 20px; right: 20px; display: flex; gap: 12px; }
.icon-btn { background: var(--card-bg); color: var(--text-main); border: 1px solid var(--input-border); width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow); }
.icon-btn svg { width: 18px; height: 18px; stroke: var(--text-main); }
/* Cards */
.card { background: var(--card-bg); border-radius: var(--border-radius); box-shadow: var(--shadow); padding: 24px; display: flex; flex-direction: column; gap: 16px; }
/* Inputs */
input[type="number"], input[type="text"] { width: 100%; padding: 10px 12px; border: 2px solid var(--input-border); border-radius: 8px; font-size: 0.95rem; background: var(--input-bg); color: var(--text-main); }
input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 3px var(--input-focus-ring); }
input.input-error { border-color: var(--error-color) !important; background: var(--error-bg); }
.error-msg { font-size: 0.78rem; color: var(--error-color); font-weight: 500; display: none; margin-top: 2px; }
.error-msg.visible { display: block; }
/* Unit toggle */
.unit-toggle { display: flex; border: 1.5px solid var(--input-border); border-radius: 6px; overflow: hidden; }
.unit-toggle button { background: none; border: none; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; color: var(--text-muted); cursor: pointer; }
.unit-toggle button.active { background: var(--primary); color: #fff; }
/* Unit row above pieces */
.unit-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.unit-row-group { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
/* Pieces table */
.pieces-col-headers { display: grid; grid-template-columns: 22px 1fr 1fr 1fr 1fr 30px; gap: 6px; padding: 0 2px; }
.pieces-col-headers span { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.piece-row { display: grid; grid-template-columns: 22px 1fr 1fr 1fr 1fr 30px; gap: 6px; align-items: center; }
.piece-num { font-size: 0.82rem; font-weight: 700; color: var(--text-muted); text-align: center; }
.piece-row input[type="number"] { padding: 8px 10px; font-size: 0.9rem; }
.piece-delete { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: 4px; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.piece-delete:hover { color: var(--error-color); }
.piece-delete svg { width: 15px; height: 15px; stroke: currentColor; pointer-events: none; }
.pieces-container { display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; padding-right: 2px; }
/* Add piece */
.add-piece-btn { display: inline-flex; align-items: center; gap: 6px; background: none; border: 1.5px dashed var(--input-border); color: var(--text-muted); padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 600; align-self: flex-start; }
.add-piece-btn:hover { border-color: var(--primary); color: var(--primary); }
.add-piece-btn svg { width: 14px; height: 14px; stroke: currentColor; }
/* Buttons */
.btn-row { display: flex; gap: 10px; margin-top: 4px; }
button.calculate-btn { flex: 1; background: var(--primary); color: white; border: none; padding: 14px; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 0.95rem; }
button.calculate-btn:hover { background: var(--primary-hover); }
button.clear-btn { background: transparent; color: var(--text-muted); border: 2px solid var(--input-border); padding: 14px 16px; border-radius: 8px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 0.88rem; white-space: nowrap; }
button.clear-btn:hover { border-color: var(--error-color); color: var(--error-color); }
button.clear-btn svg { width: 15px; height: 15px; stroke: currentColor; }
/* Results */
.results-container { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; opacity: 0.45; transition: opacity 0.3s; }
.results-container.active { opacity: 1; }
.result-box { background: var(--bg-color); border: 2px dashed var(--border-color); border-radius: var(--border-radius); padding: 28px 24px 20px; width: 100%; position: relative; }
.result-value { font-size: 3.8rem; font-weight: 800; color: var(--primary); margin: 10px 0 4px; }
.result-label { font-size: 1rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.density-info { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 14px; }
/* Density gauge */
.density-gauge { width: 100%; margin-top: 8px; }
.gauge-bar { height: 8px; border-radius: 4px; background: linear-gradient(to right, #22c55e, #f59e0b, #ef4444); position: relative; }
.gauge-marker { position: absolute; top: -5px; width: 3px; height: 18px; background: var(--text-main); border-radius: 2px; transform: translateX(-50%); transition: left 0.4s ease; left: 0%; }
.gauge-labels { display: flex; justify-content: space-between; font-size: 0.68rem; color: var(--text-muted); margin-top: 4px; }
/* Result action buttons */
.result-actions { display: flex; gap: 8px; justify-content: center; margin-top: 14px; }
.result-action-btn { display: inline-flex; align-items: center; gap: 5px; background: var(--card-bg); border: 1px solid var(--input-border); border-radius: 8px; padding: 7px 14px; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.result-action-btn:hover { border-color: var(--primary); color: var(--primary); }
.result-action-btn svg { width: 13px; height: 13px; stroke: currentColor; }
.result-action-btn.copied { color: #22c55e; border-color: #22c55e; }
/* Table */
.reference-section { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 11px 12px; border-bottom: 1px solid var(--border-color); text-align: left; font-size: 0.9rem; }
th { background: var(--table-header-bg); color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
.highlight-row { background: var(--highlight-bg) !important; font-weight: bold; color: var(--highlight-text); }
/* NMFC section */
.nmfc-section { display: flex; flex-direction: column; gap: 14px; margin-bottom: 24px; }
.nmfc-section h3 { font-size: 1rem; font-weight: 700; }
.nmfc-search-wrapper { position: relative; }
.nmfc-search-wrapper input { padding-left: 38px; }
.nmfc-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; }
.nmfc-search-icon svg { width: 16px; height: 16px; stroke: var(--text-muted); }
.nmfc-results { border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; max-height: 240px; overflow-y: auto; }
.nmfc-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 0.88rem; }
.nmfc-results table { margin: 0; }
.nmfc-results tbody tr { cursor: pointer; }
.nmfc-results tbody tr:hover { background: var(--table-hover); }
.nmfc-class-badge { display: inline-block; background: var(--primary); color: #fff; border-radius: 5px; padding: 2px 8px; font-weight: 700; font-size: 0.82rem; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: var(--modal-bg); z-index: 2000; display: none; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal-content { background: var(--card-bg); width: 90%; max-width: 500px; border-radius: var(--border-radius); box-shadow: 0 20px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; max-height: 88vh; }
.modal-header { padding: 20px 24px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.modal-header h2 { font-size: 1.1rem; font-weight: 700; }
.modal-header .icon-btn { width: 32px; height: 32px; box-shadow: none; }
.modal-header .icon-btn svg { width: 16px; height: 16px; }
.modal-tabs { display: flex; border-bottom: 1px solid var(--border-color); padding: 0 24px; flex-shrink: 0; overflow-x: auto; }
.tab-btn { background: none; border: none; padding: 12px 14px; cursor: pointer; color: var(--text-muted); border-bottom: 3px solid transparent; font-size: 0.88rem; font-weight: 500; margin-bottom: -1px; white-space: nowrap; }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
.modal-body { padding: 24px; overflow-y: auto; flex: 1; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Settings groups */
.settings-group { margin-bottom: 22px; }
.settings-group:last-child { margin-bottom: 0; }
.settings-group > label { display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; }
.settings-group input[type="text"] { padding: 10px 14px; font-size: 0.95rem; border-width: 1.5px; }
.color-row { display: flex; align-items: center; gap: 12px; }
input[type="color"] { width: 44px; height: 44px; padding: 3px; border: 1.5px solid var(--input-border); border-radius: 8px; background: var(--input-bg); cursor: pointer; }
#color-hex-label { font-family: 'Courier New', monospace; font-size: 0.9rem; color: var(--text-muted); }
.logo-upload-area { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
#logo-preview { max-height: 56px; max-width: 180px; object-fit: contain; display: none; border-radius: 6px; border: 1px solid var(--border-color); padding: 4px; }
.file-upload-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border: 1.5px solid var(--input-border); border-radius: 7px; cursor: pointer; font-size: 0.85rem; font-weight: 500; color: var(--text-muted); background: var(--input-bg); }
.file-upload-btn:hover { border-color: var(--primary); color: var(--primary); }
.file-upload-btn svg { width: 14px; height: 14px; stroke: currentColor; }
#setting-logo { display: none; }
.danger-zone { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-color); }
/* Defaults tab */
.defaults-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-bottom: 10px; }
.defaults-grid .form-group { display: flex; flex-direction: column; gap: 5px; }
.defaults-grid label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); }
.defaults-grid input { padding: 8px 10px; font-size: 0.9rem; }
/* History tab */
.history-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; flex-wrap: wrap; gap: 8px; }
.history-actions { display: flex; gap: 8px; }
.history-list { list-style: none; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 8px; max-height: 260px; }
.history-item { padding: 11px 14px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: flex-start; cursor: pointer; font-size: 0.88rem; gap: 8px; }
.history-item:last-child { border-bottom: none; }
.history-item:hover { background: var(--table-hover); }
.history-item-left { display: flex; flex-direction: column; gap: 2px; }
.history-item-timestamp { font-size: 0.7rem; color: var(--text-muted); }
.history-class { font-weight: 700; color: var(--primary); white-space: nowrap; }
/* Buttons misc */
.btn-primary { background: var(--primary); color: white; border: none; padding: 10px 18px; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 0.88rem; display: inline-flex; align-items: center; gap: 7px; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary svg { width: 14px; height: 14px; stroke: currentColor; }
.btn-secondary { background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 0.88rem; display: inline-flex; align-items: center; gap: 6px; }
.btn-secondary:hover { background: var(--table-hover); }
.btn-secondary svg { width: 14px; height: 14px; stroke: currentColor; }
.btn-danger { color: #ef4444; border-color: #fca5a5; }
.btn-danger:hover { background: rgba(239,68,68,0.1); }
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="top-controls">
<button id="theme-toggle" class="icon-btn"><i data-lucide="moon"></i></button>
<button id="settings-btn" class="icon-btn"><i data-lucide="settings"></i></button>
</div>
<div class="container">
<div class="branding-header">
<img id="business-logo" src="" alt="Logo">
<div id="business-name-display"></div>
</div>
<div class="main-title">
<h1>Freight Class Calculator</h1>
</div>
<div class="content-grid">
<!-- Left: Pieces input -->
<div class="card">
<!-- Unit toggles -->
<div class="unit-row">
<div class="unit-row-group">
Dimensions
<div class="unit-toggle" id="dim-unit-toggle">
<button class="active" data-unit="in" onclick="setDimUnit('in')">in</button>
<button data-unit="cm" onclick="setDimUnit('cm')">cm</button>
</div>
</div>
<div class="unit-row-group">
Weight
<div class="unit-toggle" id="wt-unit-toggle">
<button class="active" data-unit="lbs" onclick="setWtUnit('lbs')">lbs</button>
<button data-unit="kg" onclick="setWtUnit('kg')">kg</button>
</div>
</div>
</div>
<!-- Column headers -->
<div class="pieces-col-headers">
<span style="text-align:center">#</span>
<span id="hdr-l">L (in)</span>
<span id="hdr-w">W (in)</span>
<span id="hdr-h">H (in)</span>
<span id="hdr-wt">Wt (lbs)</span>
<span></span>
</div>
<!-- Piece rows rendered by JS -->
<div class="pieces-container" id="pieces-container"></div>
<span class="error-msg" id="err-pieces">All pieces need valid dimensions and weight.</span>
<button class="add-piece-btn" onclick="addPiece()">
<i data-lucide="plus"></i> Add Piece
</button>
<div class="btn-row">
<button class="calculate-btn" onclick="calculateFreightClass()">Calculate Class</button>
<button class="clear-btn" onclick="clearForm()"><i data-lucide="x-circle"></i> Clear</button>
</div>
</div>
<!-- Right: Results -->
<div class="card results-container" id="results-area">
<div class="result-box">
<div class="result-label">Freight Class</div>
<div class="result-value" id="class-result">--</div>
<div class="density-info">Density: <span id="density-result">0.00</span> lb/ft³</div>
<div class="density-gauge">
<div class="gauge-bar"><div class="gauge-marker" id="gauge-marker"></div></div>
<div class="gauge-labels"><span>Class 500<br><small>Low density</small></span><span style="text-align:right">Class 50<br><small>High density</small></span></div>
</div>
</div>
<div class="result-actions" id="result-actions">
<button class="result-action-btn" id="copy-btn" onclick="copyResult()"><i data-lucide="copy"></i> Copy</button>
<button class="result-action-btn" id="pdf-btn" onclick="exportPDF()"><i data-lucide="file-text"></i> PDF</button>
</div>
</div>
</div>
<!-- NMFC Lookup -->
<div class="card nmfc-section" style="margin-bottom:24px">
<h3>NMFC Commodity Lookup</h3>
<div class="nmfc-search-wrapper">
<span class="nmfc-search-icon"><i data-lucide="search"></i></span>
<input type="text" id="nmfc-search" placeholder="Search by commodity name or NMFC code…" oninput="nmfcSearch(this.value)">
</div>
<div class="nmfc-results" id="nmfc-results">
<div class="nmfc-empty">Type to search commodities</div>
</div>
</div>
<!-- Density Reference -->
<div class="card reference-section">
<h3>Density Reference</h3>
<table><thead><tr><th>Class</th><th>Min Density (lb/ft³)</th><th>Example Items</th></tr></thead>
<tbody id="ref-table-body"></tbody></table>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>Settings</h2>
<button class="icon-btn" id="modal-close-btn"><i data-lucide="x"></i></button>
</div>
<div class="modal-tabs">
<button class="tab-btn active" data-tab="branding" onclick="switchTab('branding',event)">Branding</button>
<button class="tab-btn" data-tab="defaults" onclick="switchTab('defaults',event)">Defaults</button>
<button class="tab-btn" data-tab="history" onclick="switchTab('history',event)">History</button>
</div>
<div class="modal-body">
<!-- Branding tab -->
<div id="tab-branding" class="tab-content active">
<div class="settings-group">
<label for="setting-name">Business Name</label>
<input type="text" id="setting-name" placeholder="Your company name">
</div>
<div class="settings-group">
<label>Brand Color</label>
<div class="color-row">
<input type="color" id="setting-color">
<span id="color-hex-label">#2563eb</span>
</div>
</div>
<div class="settings-group">
<label>Logo</label>
<div class="logo-upload-area">
<img id="logo-preview" src="" alt="Logo preview">
<label for="setting-logo" class="file-upload-btn">
<i data-lucide="upload"></i> Choose File
</label>
<input type="file" id="setting-logo" accept="image/*">
</div>
</div>
<div class="danger-zone">
<button class="btn-secondary btn-danger" id="reset-branding-btn">
<i data-lucide="rotate-ccw"></i> Reset Branding
</button>
</div>
</div>
<!-- Defaults tab -->
<div id="tab-defaults" class="tab-content">
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:16px;">
Save a default piece to pre-fill when you open the calculator or click "Use Default".
</p>
<div class="defaults-grid">
<div class="form-group"><label>Length (in)</label><input type="number" id="def-l" placeholder="e.g. 48" min="0" step="0.1"></div>
<div class="form-group"><label>Width (in)</label><input type="number" id="def-w" placeholder="e.g. 40" min="0" step="0.1"></div>
<div class="form-group"><label>Height (in)</label><input type="number" id="def-h" placeholder="e.g. 48" min="0" step="0.1"></div>
</div>
<div class="defaults-grid" style="grid-template-columns:1fr 1fr;">
<div class="form-group"><label>Weight (lbs)</label><input type="number" id="def-wt" placeholder="e.g. 500" min="0" step="0.1"></div>
</div>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-primary" id="save-defaults-btn"><i data-lucide="save"></i> Save Default</button>
<button class="btn-secondary" id="apply-defaults-btn"><i data-lucide="copy-check"></i> Use Default Now</button>
<button class="btn-secondary btn-danger" id="clear-defaults-btn"><i data-lucide="x"></i> Clear Default</button>
</div>
</div>
<!-- History tab -->
<div id="tab-history" class="tab-content">
<div class="history-controls">
<div class="history-actions">
<button class="btn-secondary" id="export-history-btn"><i data-lucide="download"></i> Export CSV</button>
<label class="btn-secondary" style="cursor:pointer;"><i data-lucide="upload"></i> Import CSV<input type="file" id="import-history-file" accept=".csv" style="display:none"></label>
</div>
<button class="btn-secondary btn-danger" id="clear-history-btn"><i data-lucide="trash-2"></i> Clear All</button>
</div>
<div id="history-list" class="history-list"></div>
</div>
</div>
<div class="modal-footer" id="modal-footer">
<button class="btn-secondary" id="modal-cancel-btn">Cancel</button>
<button class="btn-primary" id="modal-save-btn"><i data-lucide="save"></i> Save Changes</button>
</div>
</div>
</div>
<script>
// ─── Data ──────────────────────────────────────────────────────────────────────
const densityRanges = [
{ min:50, class:50, desc:'Heavy machinery' },
{ min:35, class:55, desc:'Bricks, cement' },
{ min:30, class:60, desc:'Car parts' },
{ min:22.5, class:65, desc:'Car parts, books' },
{ min:15, class:70, desc:'Food items' },
{ min:13.5, class:77.5, desc:'Tires' },
{ min:12, class:85, desc:'Crated machinery' },
{ min:10.5, class:92.5, desc:'Computers' },
{ min:9, class:100, desc:'Boat motors' },
{ min:8, class:110, desc:'TVs' },
{ min:7, class:125, desc:'Furniture' },
{ min:6, class:150, desc:'Sheet metal' },
{ min:5, class:175, desc:'Clothing' },
{ min:4, class:200, desc:'Aircraft parts' },
{ min:3, class:250, desc:'Mattresses' },
{ min:2, class:300, desc:'Cabinets' },
{ min:1, class:400, desc:'Deer antlers' },
{ min:-1, class:500, desc:'Gold dust' }
];
const nmfcData = [
{ code:'16030', desc:'Automobiles, NOI', cls:85 },
{ code:'22500', desc:'Batteries, Electric Storage', cls:70 },
{ code:'23340', desc:'Beverages, Alcoholic, NOI', cls:70 },
{ code:'28220', desc:'Books, Bound, NOI', cls:70 },
{ code:'37720', desc:'Cabinets, Wood', cls:150 },
{ code:'38420', desc:'Cameras / Photography Equipment', cls:100 },
{ code:'40560', desc:'Carpet / Rugs', cls:100 },
{ code:'46030', desc:'Clothing / Wearing Apparel, NOI', cls:175 },
{ code:'49700', desc:'Computers / Peripherals, NOI', cls:100 },
{ code:'53460', desc:'Desks, Office, NOI', cls:125 },
{ code:'55650', desc:'Electronics, Consumer, NOI', cls:125 },
{ code:'60390', desc:'Engines, Internal Combustion, NOI', cls:85 },
{ code:'61300', desc:'Equipment, Agricultural, NOI', cls:85 },
{ code:'64500', desc:'Exercise / Fitness Equipment, NOI', cls:92.5 },
{ code:'65700', desc:'Fixtures, Lighting, NOI', cls:100 },
{ code:'68100', desc:'Food, Canned, NOI', cls:55 },
{ code:'68240', desc:'Food, Frozen, NOI', cls:65 },
{ code:'69480', desc:'Foodstuffs, Dry, NOI', cls:65 },
{ code:'72800', desc:'Garden / Lawn Equipment, NOI', cls:85 },
{ code:'82950', desc:'Furniture, Household, NOI', cls:125 },
{ code:'83520', desc:'Furniture, Office, NOI', cls:125 },
{ code:'84470', desc:'Glass, Window / Plate', cls:200 },
{ code:'85820', desc:'Grains, NOI', cls:55 },
{ code:'86800', desc:'Hardware, NOI', cls:70 },
{ code:'88685', desc:'HVAC Equipment, NOI', cls:85 },
{ code:'92420', desc:'Iron or Steel Articles, NOI', cls:70 },
{ code:'93900', desc:'Janitorial / Cleaning Supplies, NOI', cls:85 },
{ code:'97460', desc:'Lumber / Wood Boards, NOI', cls:60 },
{ code:'100440', desc:'Medical Devices / Equipment, NOI', cls:100 },
{ code:'107100', desc:'Machinery, NOI', cls:85 },
{ code:'108400', desc:'Mattresses / Box Springs', cls:250 },
{ code:'109740', desc:'Metal Articles, NOI', cls:70 },
{ code:'113980', desc:'Motors, Electric, NOI', cls:85 },
{ code:'115440', desc:'Musical Instruments, NOI', cls:150 },
{ code:'119300', desc:'Paper, NOI', cls:70 },
{ code:'122400', desc:'Paints / Lacquers, NOI', cls:70 },
{ code:'123240', desc:'Pallets, Wood, Empty', cls:150 },
{ code:'125100', desc:'Pharmaceuticals, NOI', cls:85 },
{ code:'126780', desc:'Pipes, Metal, NOI', cls:70 },
{ code:'132800', desc:'Plastics Articles, NOI', cls:85 },
{ code:'137900', desc:'Printed Matter / Publications, NOI', cls:70 },
{ code:'145600', desc:'Rubber Articles, NOI', cls:85 },
{ code:'148240', desc:'Safety Equipment, NOI', cls:100 },
{ code:'150150', desc:'Sheet Metal, NOI', cls:150 },
{ code:'155900', desc:'Steel, Structural, NOI', cls:50 },
{ code:'157100', desc:'Stones, Granite / Marble, Slabs', cls:55 },
{ code:'160400', desc:'Tires, Pneumatic, NOI', cls:77.5 },
{ code:'161320', desc:'Tools, Hand, NOI', cls:70 },
{ code:'161440', desc:'Tools, Power, NOI', cls:85 },
{ code:'162630', desc:'Toys / Games, NOI', cls:85 },
{ code:'168540', desc:'Televisions / Monitors', cls:110 },
{ code:'172000', desc:'Textiles, NOI', cls:125 },
{ code:'174900', desc:'Trailers, NOI', cls:85 },
{ code:'178260', desc:'Vehicle Parts / Accessories, NOI', cls:85 },
{ code:'182600', desc:'Wire, Electrical, NOI', cls:70 },
{ code:'183600', desc:'Wood Articles, NOI', cls:100 },
];
// ─── State ────────────────────────────────────────────────────────────────────
let appState = JSON.parse(localStorage.getItem('freightCalcState')) || {};
appState = Object.assign({
theme: 'light',
branding: { name:'', color:'#2563eb', logo:'' },
history: [],
units: { dim:'in', wt:'lbs' },
defaults: { L:'', W:'', H:'', Weight:'' }
}, appState);
if (!appState.units) appState.units = { dim:'in', wt:'lbs' };
if (!appState.defaults) appState.defaults = { L:'', W:'', H:'', Weight:'' };
let pendingBranding = null;
// Working pieces array (ephemeral, not persisted)
let pieces = [{ L:'', W:'', H:'', Weight:'' }];
// Race-condition guards: track in-flight timers/readers so rapid repeat
// actions don't let a stale callback clobber a newer one.
let copyRevertTimer = null;
let saveDefaultsRevertTimer = null;
let logoReadToken = 0;
let csvImportToken = 0;
function saveState() { localStorage.setItem('freightCalcState', JSON.stringify(appState)); }
// ─── Init ──────────────────────────────────────────────────────────────────────
window.onload = () => {
document.body.className = appState.theme === 'dark' ? 'dark-mode' : '';
document.getElementById('theme-toggle').innerHTML = appState.theme === 'dark'
? '<i data-lucide="sun"></i>' : '<i data-lucide="moon"></i>';
document.documentElement.style.setProperty('--primary', appState.branding.color);
applyBrandingToPage();
renderHistory();
renderPieces();
applyUnitToggleUI();
// Pre-fill defaults if set
if (appState.defaults.L) {
pieces = [{ L: appState.defaults.L, W: appState.defaults.W, H: appState.defaults.H, Weight: appState.defaults.Weight }];
renderPieces();
}
// Density reference table
densityRanges.forEach(r => {
const row = document.createElement('tr'); row.id = `row-${r.class}`;
row.innerHTML = `<td>Class ${r.class}</td><td>${r.min}+ lbs/ft³</td><td>${r.desc}</td>`;
document.getElementById('ref-table-body').appendChild(row);
});
lucide.createIcons();
};
// ─── Theme ────────────────────────────────────────────────────────────────────
document.getElementById('theme-toggle').onclick = () => {
document.body.classList.toggle('dark-mode');
appState.theme = document.body.classList.contains('dark-mode') ? 'dark' : 'light';
document.getElementById('theme-toggle').innerHTML = appState.theme === 'dark'
? '<i data-lucide="sun"></i>' : '<i data-lucide="moon"></i>';
lucide.createIcons();
saveState();
};
// ─── Branding ─────────────────────────────────────────────────────────────────
function applyBrandingToPage() {
const b = appState.branding;
const header = document.querySelector('.branding-header');
const logo = document.getElementById('business-logo');
const nameEl = document.getElementById('business-name-display');
logo.src = b.logo || '';
logo.style.display = b.logo ? 'block' : 'none';
nameEl.textContent = b.name;
nameEl.style.display = b.name ? 'block' : 'none';
header.style.display = (b.logo || b.name) ? 'flex' : 'none';
}
function populateSettingsForm() {
const b = appState.branding;
document.getElementById('setting-name').value = b.name;
document.getElementById('setting-color').value = b.color;
document.getElementById('color-hex-label').textContent = b.color;
const preview = document.getElementById('logo-preview');
if (b.logo) { preview.src = b.logo; preview.style.display = 'block'; }
else { preview.src = ''; preview.style.display = 'none'; }
}
function populateDefaultsForm() {
const d = appState.defaults;
document.getElementById('def-l').value = d.L || '';
document.getElementById('def-w').value = d.W || '';
document.getElementById('def-h').value = d.H || '';
document.getElementById('def-wt').value = d.Weight || '';
}
document.getElementById('setting-name').oninput = e => {
appState.branding.name = e.target.value; applyBrandingToPage();
};
document.getElementById('setting-color').oninput = e => {
appState.branding.color = e.target.value;
document.getElementById('color-hex-label').textContent = e.target.value;
document.documentElement.style.setProperty('--primary', e.target.value);
};
document.getElementById('setting-logo').onchange = e => {
if (!e.target.files[0]) return;
const myToken = ++logoReadToken;
const reader = new FileReader();
reader.onload = evt => {
if (myToken !== logoReadToken) return; // a newer file selection superseded this read
appState.branding.logo = evt.target.result;
const preview = document.getElementById('logo-preview');
preview.src = evt.target.result; preview.style.display = 'block';
applyBrandingToPage();
};
reader.readAsDataURL(e.target.files[0]);
};
document.getElementById('reset-branding-btn').onclick = () => {
appState.branding = { name:'', color:'#2563eb', logo:'' };
document.documentElement.style.setProperty('--primary', '#2563eb');
applyBrandingToPage(); populateSettingsForm();
};
// ─── Defaults tab ─────────────────────────────────────────────────────────────
document.getElementById('save-defaults-btn').onclick = () => {
appState.defaults = {
L: document.getElementById('def-l').value,
W: document.getElementById('def-w').value,
H: document.getElementById('def-h').value,
Weight: document.getElementById('def-wt').value
};
saveState();
const btn = document.getElementById('save-defaults-btn');
if (saveDefaultsRevertTimer) clearTimeout(saveDefaultsRevertTimer);
btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons();
saveDefaultsRevertTimer = setTimeout(() => {
btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons();
saveDefaultsRevertTimer = null;
}, 1800);
};
document.getElementById('apply-defaults-btn').onclick = () => {
const d = appState.defaults;
if (!d.L) return;
pieces = [{ L: d.L, W: d.W, H: d.H, Weight: d.Weight }];
renderPieces();
revertAndClose();
};
document.getElementById('clear-defaults-btn').onclick = () => {
appState.defaults = { L:'', W:'', H:'', Weight:'' };
saveState(); populateDefaultsForm();
};
// ─── Modal ────────────────────────────────────────────────────────────────────
document.getElementById('settings-btn').onclick = () => {
pendingBranding = JSON.parse(JSON.stringify(appState.branding));
populateSettingsForm();
populateDefaultsForm();
switchTab('branding');
document.getElementById('settings-modal').classList.add('open');
lucide.createIcons();
};
function revertAndClose() {
if (pendingBranding) {
appState.branding = pendingBranding;
document.documentElement.style.setProperty('--primary', pendingBranding.color);
applyBrandingToPage();
pendingBranding = null;
}
document.getElementById('settings-modal').classList.remove('open');
}
document.getElementById('modal-close-btn').onclick = revertAndClose;
document.getElementById('modal-cancel-btn').onclick = revertAndClose;
document.getElementById('modal-save-btn').onclick = () => {
saveState(); pendingBranding = null;
document.getElementById('settings-modal').classList.remove('open');
};
// ─── Tabs ─────────────────────────────────────────────────────────────────────
function switchTab(t, e) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + t).classList.add('active');
const btn = e ? e.target : document.querySelector(`.tab-btn[data-tab="${t}"]`);
if (btn) btn.classList.add('active');
document.getElementById('modal-footer').style.display = t === 'branding' ? 'flex' : 'none';
}
// ─── History ──────────────────────────────────────────────────────────────────
document.getElementById('clear-history-btn').onclick = () => {
appState.history = []; saveState(); renderHistory();
};
document.getElementById('export-history-btn').onclick = exportHistoryCSV;
document.getElementById('import-history-file').onchange = importHistoryCSV;
function formatTimestamp(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month:'short', day:'numeric' }) + ' ' +
d.toLocaleTimeString(undefined, { hour:'2-digit', minute:'2-digit' });
}
function renderHistory() {
const list = document.getElementById('history-list');
list.innerHTML = '';
if (!appState.history.length) {
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:0.88rem">No calculations yet</div>';
return;
}
appState.history.forEach(item => {
const div = document.createElement('div'); div.className = 'history-item';
const pArr = item.pieces || [{ L:item.L, W:item.W, H:item.H, Weight:item.Weight }];
const storedDim = item.units?.dim || 'in';
const storedWt = item.units?.wt || 'lbs';
const dimSym = storedDim === 'in' ? '"' : ' cm';
const summary = pArr.length === 1
? `${pArr[0].L}${dimSym}×${pArr[0].W}${dimSym}×${pArr[0].H}${dimSym} · ${pArr[0].Weight} ${storedWt}`
: `${pArr.length} pieces · ${pArr.reduce((s,p)=>s+parseFloat(p.Weight||0),0).toFixed(1)} ${storedWt} total`;
div.innerHTML = `
<div class="history-item-left">
<span>${summary}</span>
<span class="history-item-timestamp">${formatTimestamp(item.timestamp)}</span>
</div>
<span class="history-class">Class ${item.Class}</span>`;
div.onclick = () => {
pieces = pArr.map(p => ({ L:p.L, W:p.W, H:p.H, Weight:p.Weight }));
// Restore to the exact units the values were saved in — no conversion needed
appState.units.dim = storedDim;
appState.units.wt = storedWt;
saveState();
applyUnitToggleUI();
renderPieces();
document.getElementById('density-result').textContent = item.Density;
document.getElementById('class-result').textContent = item.Class;
document.getElementById('results-area').classList.add('active');
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
const row = document.getElementById(`row-${item.Class}`);
if (row) row.classList.add('highlight-row');
updateGauge(parseFloat(item.Density));
clearErrors();
revertAndClose();
};
list.appendChild(div);
});
}
// Export history as CSV
function exportHistoryCSV() {
if (!appState.history.length) return;
const rows = [['Timestamp','Pieces','Total Weight (lbs)','Density (lb/ft³)','Freight Class']];
appState.history.forEach(item => {
const pArr = item.pieces || [{ L:item.L, W:item.W, H:item.H, Weight:item.Weight }];
const totalWt = pArr.reduce((s,p)=>s+parseFloat(p.Weight||0),0).toFixed(1);
const piecesStr = pArr.map((p,i)=>`P${i+1}:${p.L}x${p.W}x${p.H}/${p.Weight}lbs`).join('; ');
rows.push([item.timestamp||'', piecesStr, totalWt, item.Density, item.Class]);
});
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type:'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `freight-history-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
// Defer revocation generously so slower systems/rapid consecutive exports
// have time to actually start the download before the blob URL dies.
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// Import history from CSV
function importHistoryCSV(e) {
const file = e.target.files[0]; if (!file) return;
const myToken = ++csvImportToken;
const reader = new FileReader();
reader.onload = evt => {
if (myToken !== csvImportToken) return; // a newer import selection superseded this read
const lines = evt.target.result.trim().split('\n').slice(1); // skip header
const imported = [];
lines.forEach(line => {
const cols = line.match(/(".*?"|[^,]+)/g) || [];
const clean = cols.map(c => c.replace(/^"|"$/g,'').replace(/""/g,'"'));
if (clean.length < 5) return;
const [timestamp, piecesStr, , density, cls] = clean;
const pArr = piecesStr.split(';').map(ps => {
const m = ps.trim().match(/P\d+:([\d.]+)x([\d.]+)x([\d.]+)\/([\d.]+)lbs/);
return m ? { L:m[1], W:m[2], H:m[3], Weight:m[4] } : null;
}).filter(Boolean);
if (!pArr.length) return;
imported.push({ pieces:pArr, L:pArr[0].L, W:pArr[0].W, H:pArr[0].H, Weight:pArr[0].Weight,
Density:density, Class:parseFloat(cls), timestamp });
});
if (imported.length) {
appState.history = [...imported, ...appState.history].slice(0, 50);
saveState(); renderHistory();
}
e.target.value = '';
};
reader.readAsText(file);
}
// ─── Unit toggles ─────────────────────────────────────────────────────────────
function applyUnitToggleUI() {
const du = appState.units.dim;
const wu = appState.units.wt;
document.querySelectorAll('#dim-unit-toggle button').forEach(b => b.classList.toggle('active', b.dataset.unit === du));
document.querySelectorAll('#wt-unit-toggle button').forEach(b => b.classList.toggle('active', b.dataset.unit === wu));
document.getElementById('hdr-l').textContent = `L (${du})`;
document.getElementById('hdr-w').textContent = `W (${du})`;
document.getElementById('hdr-h').textContent = `H (${du})`;
document.getElementById('hdr-wt').textContent = `Wt (${wu})`;
lucide.createIcons();
}
function setDimUnit(newUnit) {
const oldUnit = appState.units.dim; if (oldUnit === newUnit) return;
const factor = newUnit === 'cm' ? 2.54 : (1/2.54);
pieces.forEach(p => {
if (p.L) p.L = parseFloat((parseFloat(p.L)*factor).toFixed(2));
if (p.W) p.W = parseFloat((parseFloat(p.W)*factor).toFixed(2));
if (p.H) p.H = parseFloat((parseFloat(p.H)*factor).toFixed(2));
});
appState.units.dim = newUnit; saveState();
renderPieces(); applyUnitToggleUI();
}
function setWtUnit(newUnit) {
const oldUnit = appState.units.wt; if (oldUnit === newUnit) return;
const factor = newUnit === 'kg' ? (1/2.20462) : 2.20462;
pieces.forEach(p => {
if (p.Weight) p.Weight = parseFloat((parseFloat(p.Weight)*factor).toFixed(2));
});
appState.units.wt = newUnit; saveState();
renderPieces(); applyUnitToggleUI();
}
// ─── Pieces ───────────────────────────────────────────────────────────────────
function renderPieces() {
const container = document.getElementById('pieces-container');
container.innerHTML = '';
pieces.forEach((p, i) => {
const row = document.createElement('div'); row.className = 'piece-row';
const canDelete = pieces.length > 1;
row.innerHTML = `
<span class="piece-num">${i+1}</span>
<input type="number" class="dim-input" value="${p.L}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="L">
<input type="number" class="dim-input" value="${p.W}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="W">
<input type="number" class="dim-input" value="${p.H}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="H">
<input type="number" value="${p.Weight}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="Weight">
<button class="piece-delete" ${canDelete ? '' : 'style="visibility:hidden"'} data-idx="${i}">
<i data-lucide="x"></i>
</button>`;
container.appendChild(row);
});
// Bind input events
container.querySelectorAll('input[type="number"]').forEach(el => {
el.addEventListener('input', e => { pieces[parseInt(e.target.dataset.idx, 10)][e.target.dataset.field] = e.target.value; });
el.addEventListener('keydown', e => { if (e.key === 'Enter') calculateFreightClass(); });
});
// Bind delete buttons
container.querySelectorAll('.piece-delete').forEach(btn => {
btn.addEventListener('click', e => {
const idx = parseInt(e.currentTarget.dataset.idx);
pieces.splice(idx, 1);
renderPieces();
});
});
lucide.createIcons();
}
function addPiece() {
pieces.push({ L:'', W:'', H:'', Weight:'' });
renderPieces();
// Focus first input of new row
const rows = document.querySelectorAll('.piece-row');
const lastRow = rows[rows.length-1];
if (lastRow) {
const firstInput = lastRow.querySelector('input');
if (firstInput) firstInput.focus();
}
}
// ─── Validation & Clear ───────────────────────────────────────────────────────
function clearErrors() {
document.querySelectorAll('.input-error').forEach(el => el.classList.remove('input-error'));
document.getElementById('err-pieces').classList.remove('visible');
}
function clearForm() {
pieces = [{ L:'', W:'', H:'', Weight:'' }];
renderPieces();
document.getElementById('class-result').textContent = '--';
document.getElementById('density-result').textContent = '0.00';
document.getElementById('results-area').classList.remove('active');
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
document.getElementById('gauge-marker').style.left = '0%';
clearErrors();
}
// ─── Density gauge ────────────────────────────────────────────────────────────
function updateGauge(density) {
// Gauge goes left=0 (low density / high class) to right=50+ (high density / low class)
const pct = Math.min(Math.max(density / 50, 0), 1) * 100;
document.getElementById('gauge-marker').style.left = pct + '%';
}
// ─── Calculate ────────────────────────────────────────────────────────────────
function calculateFreightClass() {
clearErrors();
const dimFactor = appState.units.dim === 'cm' ? (1/2.54) : 1;
const wtFactor = appState.units.wt === 'kg' ? 2.20462 : 1;
let valid = true;
let totalCuFt = 0, totalWt = 0;
pieces.forEach((p, i) => {
const L = parseFloat(p.L);
const W = parseFloat(p.W);
const H = parseFloat(p.H);
const WT = parseFloat(p.Weight);
if (!L || !W || !H || !WT || L<=0 || W<=0 || H<=0 || WT<=0) {
valid = false;
const rows = document.querySelectorAll('.piece-row');
if (rows[i]) rows[i].querySelectorAll('input').forEach(el => {
if (!parseFloat(el.value) || parseFloat(el.value)<=0) el.classList.add('input-error');
});
return; // skip accumulation — prevents NaN from propagating into totals
}
totalCuFt += (L * dimFactor * W * dimFactor * H * dimFactor) / 1728;
totalWt += WT * wtFactor;
});
if (!valid) {
document.getElementById('err-pieces').classList.add('visible');
return;
}
const density = totalWt / totalCuFt;
let cls = 500;
for (let r of densityRanges) if (density >= r.min) { cls = r.class; break; }
// Save to history — store units so restore and display are always correct
const histItem = {
pieces: pieces.map(p => ({ L:p.L, W:p.W, H:p.H, Weight:p.Weight })),
L: pieces[0].L, W: pieces[0].W, H: pieces[0].H, Weight: pieces[0].Weight,
Density: density.toFixed(2),
Class: cls,
timestamp: new Date().toISOString(),
units: { dim: appState.units.dim, wt: appState.units.wt }
};
appState.history.unshift(histItem);
if (appState.history.length > 50) appState.history.pop();
saveState(); renderHistory();
document.getElementById('density-result').textContent = density.toFixed(2);
document.getElementById('class-result').textContent = cls;
document.getElementById('results-area').classList.add('active');
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
const refRow = document.getElementById(`row-${cls}`);
if (refRow) refRow.classList.add('highlight-row');
updateGauge(density);
lucide.createIcons();
}
// Returns true once a calculation has produced a result (i.e. not the placeholder '--')
function hasResult() {
return document.getElementById('class-result').textContent !== '--';
}
// ─── Copy to clipboard ────────────────────────────────────────────────────────
function copyResult() {
if (!hasResult()) return;
const cls = document.getElementById('class-result').textContent;
const density = document.getElementById('density-result').textContent;
const du = appState.units.dim, wu = appState.units.wt;
const pLines = pieces.map((p,i)=>` Piece ${i+1}: ${p.L}×${p.W}×${p.H} ${du}, ${p.Weight} ${wu}`).join('\n');
const text = `Freight Class: ${cls}\nDensity: ${density} lb/ft³\n${pLines}`;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn');
if (copyRevertTimer) clearTimeout(copyRevertTimer);
btn.classList.add('copied');
btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons();
copyRevertTimer = setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons();
copyRevertTimer = null;
}, 2000);
});
}
// ─── PDF Export ───────────────────────────────────────────────────────────────
function exportPDF() {
if (!hasResult()) {
alert('Please calculate a freight class before exporting a PDF.');
return;
}
if (typeof window.jspdf === 'undefined') { alert('PDF library not loaded. Please check your internet connection.'); return; }
// Guard against stale/edited piece data producing Infinity/NaN in the PDF
// (e.g. user calculates, then clears a dimension before exporting without recalculating).
const piecesValidForExport = pieces.every(p => {
const L = parseFloat(p.L), Wd = parseFloat(p.W), H = parseFloat(p.H), Wt = parseFloat(p.Weight);
return L > 0 && Wd > 0 && H > 0 && Wt > 0;
});
if (!piecesValidForExport) {
alert('Piece values have changed since the last calculation. Please recalculate before exporting a PDF.');
return;
}
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit:'mm', format:'letter' });
const W = doc.internal.pageSize.getWidth();
const brandColor = hexToRgb(appState.branding.color || '#2563eb');
const cls = document.getElementById('class-result').textContent;
const density = document.getElementById('density-result').textContent;
const du = appState.units.dim, wu = appState.units.wt;
const now = new Date().toLocaleString();
let y = 18;
// Header bar
doc.setFillColor(brandColor.r, brandColor.g, brandColor.b);
doc.rect(0, 0, W, 28, 'F');
// Logo — detect format from data URL so JPG/PNG/WEBP all work
if (appState.branding.logo) {
try { doc.addImage(appState.branding.logo, getImageFormat(appState.branding.logo), 10, 4, 0, 20); } catch(e) {}
}
// Company name
doc.setTextColor(255, 255, 255);
doc.setFontSize(16); doc.setFont('helvetica','bold');
const companyName = appState.branding.name || 'Freight Calculator Pro';
doc.text(companyName, W/2, 13, { align:'center' });
doc.setFontSize(9); doc.setFont('helvetica','normal');
doc.text('Freight Quote', W/2, 21, { align:'center' });
y = 40;
// Date
doc.setTextColor(100, 116, 139); doc.setFontSize(8);
doc.text(`Generated: ${now}`, W - 14, y - 6, { align:'right' });
// Class result box
doc.setDrawColor(brandColor.r, brandColor.g, brandColor.b);
doc.setFillColor(240, 245, 255);
doc.roundedRect(14, y, W - 28, 28, 3, 3, 'FD');
doc.setTextColor(brandColor.r, brandColor.g, brandColor.b);
doc.setFontSize(32); doc.setFont('helvetica','bold');
doc.text(`Class ${cls}`, W/2, y + 18, { align:'center' });
doc.setFontSize(10); doc.setFont('helvetica','normal');
doc.setTextColor(100, 116, 139);
doc.text(`Density: ${density} lb/ft³`, W/2, y + 25, { align:'center' });
y += 38;
// Pieces table
doc.setFontSize(11); doc.setFont('helvetica','bold'); doc.setTextColor(30, 41, 59);
doc.text('Shipment Pieces', 14, y); y += 5;
const dimFactor = du === 'cm' ? (1/2.54) : 1;
const wtFactor = wu === 'kg' ? 2.20462 : 1;
const tableBody = pieces.map((p, i) => {
const Lin = parseFloat(p.L)*dimFactor, Win = parseFloat(p.W)*dimFactor, Hin = parseFloat(p.H)*dimFactor;
const WTlb = parseFloat(p.Weight)*wtFactor;
const cuFt = (Lin*Win*Hin)/1728;
const pcf = WTlb / cuFt;
return [
i+1,
`${p.L} ${du}`,
`${p.W} ${du}`,
`${p.H} ${du}`,
`${p.Weight} ${wu}`,
cuFt.toFixed(3),
pcf.toFixed(2)
];
});
const totalWtLb = pieces.reduce((s,p)=>s+parseFloat(p.Weight||0)*(wu==='kg'?2.20462:1),0);
const totalCuFt = pieces.reduce((p,pc)=>{
const L=parseFloat(pc.L)*dimFactor, Ww=parseFloat(pc.W)*dimFactor, H=parseFloat(pc.H)*dimFactor;
return p+(L*Ww*H)/1728;
},0);
doc.autoTable({
startY: y,
head: [['#','Length','Width','Height','Weight','Cu Ft','PCF']],
body: tableBody,
foot: [['','','','',`Total: ${totalWtLb.toFixed(1)} lbs`, `${totalCuFt.toFixed(3)}`, `${(totalWtLb/totalCuFt).toFixed(2)}`]],
theme: 'grid',
headStyles: { fillColor:[brandColor.r, brandColor.g, brandColor.b], textColor:255, fontStyle:'bold', fontSize:9 },
footStyles: { fillColor:[240,245,255], textColor:[30,41,59], fontStyle:'bold', fontSize:9 },
bodyStyles: { fontSize:9, textColor:[30,41,59] },
alternateRowStyles: { fillColor:[248,250,252] },
margin: { left:14, right:14 },
columnStyles: { 0:{cellWidth:10, halign:'center'} }
});
// Footer
const pageH = doc.internal.pageSize.getHeight();
doc.setFontSize(7); doc.setTextColor(148,163,184);
doc.text('Generated by Freight Calculator Pro', W/2, pageH-8, { align:'center' });
doc.save(`freight-class-${cls}-${new Date().toISOString().slice(0,10)}.pdf`);
}
function getImageFormat(dataUrl) {
if (/^data:image\/jpe?g/i.test(dataUrl)) return 'JPEG';
if (/^data:image\/png/i.test(dataUrl)) return 'PNG';
if (/^data:image\/gif/i.test(dataUrl)) return 'GIF';
if (/^data:image\/webp/i.test(dataUrl)) return 'WEBP';
return 'PNG';
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? { r:parseInt(result[1],16), g:parseInt(result[2],16), b:parseInt(result[3],16) } : { r:37, g:99, b:235 };
}
// ─── NMFC Search ──────────────────────────────────────────────────────────────
function nmfcSearch(query) {
const container = document.getElementById('nmfc-results');
const q = query.trim().toLowerCase();
if (!q) {
container.innerHTML = '<div class="nmfc-empty">Type to search commodities</div>';
return;
}
const results = nmfcData.filter(n =>
n.desc.toLowerCase().includes(q) || n.code.includes(q)
).slice(0, 20);
if (!results.length) {
container.innerHTML = '<div class="nmfc-empty">No matching commodities found</div>';
return;
}
let html = '<table><thead><tr><th>NMFC Code</th><th>Description</th><th>Std Class</th></tr></thead><tbody>';
results.forEach(n => {
html += `<tr data-cls="${n.cls}">
<td style="font-family:monospace;font-size:0.85rem">${n.code}</td>
<td>${n.desc}</td>
<td><span class="nmfc-class-badge">${n.cls}</span></td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
container.querySelectorAll('tbody tr').forEach(row => {
row.addEventListener('click', () => nmfcSelect(parseFloat(row.dataset.cls)));
});
}
function nmfcSelect(cls) {
// Highlight the matching row in the density reference table
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
const row = document.getElementById(`row-${cls}`);
if (row) {
row.classList.add('highlight-row');
row.scrollIntoView({ behavior:'smooth', block:'center' });
}
}
</script>
</body>
</html>