Files
FreightCalculatorPro/index.html
T
Devon Bontrager fc88d7ab4b v1.2.0 — Multi-piece, NMFC lookup, PDF export, defaults, CSV history, density gauge + bug fixes
Features added:
- Multi-piece support: add unlimited pieces each with L x W x H x Weight, combined class from total density
- NMFC commodity lookup: 55 bundled items, live search by name or code
- PDF export: branded PDF with piece table, Cu Ft, PCF, freight class via jsPDF
- Default piece preset: save a standard pallet size in Settings > Defaults
- Export/import history as CSV
- Density gauge: color gradient bar with live marker in results panel
- Unit toggle: in/cm and lbs/kg with live conversion across all pieces
- Copy to clipboard, timestamps in history, Enter key shortcut, input validation
- Charcoal dark mode

Bug fixes:
- PDF and Copy now blocked before a calculation is performed
- History restore no longer misconverts values when units differ from stored unit
- History display shows correct unit labels (cm/kg) instead of hardcoded in/lbs
- PDF logo format auto-detected from data URL (fixes silent failure for JPG uploads)
- Removed dead parsedPieces variable; forEach with early return prevents NaN accumulation
- Removed no-op placeholder update in applyUnitToggleUI
- CSV export URL revocation deferred to avoid race condition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:54:52 -04:00

1055 lines
58 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>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/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:'' }];
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 reader = new FileReader();
reader.onload = evt => {
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');
btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons();
setTimeout(() => { btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons(); }, 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();
setTimeout(() => URL.revokeObjectURL(a.href), 100);
}
// Import history from CSV
function importHistoryCSV(e) {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = evt => {
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[e.target.dataset.idx][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) lastRow.querySelector('input') && lastRow.querySelector('input').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();
}
// ─── Copy to clipboard ────────────────────────────────────────────────────────
function copyResult() {
if (document.getElementById('class-result').textContent === '--') 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');
btn.classList.add('copied');
btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons();
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons(); }, 2000);
});
}
// ─── PDF Export ───────────────────────────────────────────────────────────────
function exportPDF() {
if (document.getElementById('class-result').textContent === '--') {
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; }
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 onclick="nmfcSelect(${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;
}
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>