/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // JBREWS DMS — Trade-In Requests (sales features, lane 2). Route "trades". // // Salesperson : "Send a trade" (VIN look-up, miles, condition, payoff, customer, up to 8 photos made // smaller in the browser) and "My trades" (status; once valued: the OFFER TO PRESENT and // the manager's note). A salesperson never sees ACV, the market value, MSRP or a range — // the server never sends them to a Sales login (/api/trades TradeView). // Sales manager: the trade queue (photos, decoded vehicle, MarketCheck market value, ACV, offer to // present, notes, "Not taken"). Sending the offer alerts the salesperson and writes the // numbers onto the appraisal / lead / desked deal (server side). // // Server: /api/trades/* and GET /api/sales-alerts/me (feature flags). Without them (an older server) the // screen shows #tre-unavailable. Photos are fetched with DMS.apiBlob and shown ONLY inside . // Hand-offs (localStorage, read once): jb_trade_lead (CRM -> prefill), jb_trade_focus (open one trade). // Every top-level name here starts with tre / Tre. const TRE_COND_FLAGS = [ ["accident", "Accident history"], ["mechanical", "Mechanical problems"], ["warning_lights", "Warning lights on"], ["tires", "Tires need replacing"], ["glass", "Cracked windshield"], ["interior", "Interior damage"], ["smoker", "Smoked in"], ["pets", "Pet odor/hair"], ["aftermarket", "Aftermarket parts"], ["title", "Title problems"], ]; const TRE_OVERALL = ["Excellent", "Good", "Fair", "Rough"]; const TRE_PHOTO_LABELS = ["Front", "Rear", "Driver side", "Passenger side", "Interior", "Odometer", "Damage", "Other"]; const TRE_MAX_PHOTOS = 8; // ── small helpers ─────────────────────────────────────────────────────────── const treIsMgr = () => !!(window.DMS && ((DMS.isAdmin && DMS.isAdmin()) || (DMS.roleIs && DMS.roleIs("Sales Manager")))); const treIsSales = () => !!(window.DMS && DMS.roleIs && DMS.roleIs("Sales")); const treNormVin = (v) => String(v || "").toUpperCase().replace(/[\s-]/g, ""); const treIsVin = (v) => /^[A-HJ-NPR-Z0-9]{17}$/.test(treNormVin(v)); const treMoney = (n) => { const v = Number(n); if (n === null || n === undefined || n === "" || !isFinite(v)) return "—"; return "$" + v.toLocaleString("en-US", { minimumFractionDigits: Math.round(v) === v ? 0 : 2, maximumFractionDigits: 2 }); }; const treMiles = (n) => (n === null || n === undefined || n === "" ? "—" : Number(n).toLocaleString("en-US") + " mi"); // date-only "YYYY-MM-DD" -> "Mon Oct 12" built from its parts (never new Date("YYYY-MM-DD")) const treFmtDay = (day) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(day || "")); if (!m) return ""; return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toLocaleDateString("en-US", { timeZone: "UTC", weekday: "short", month: "short", day: "numeric" }).replace(/,/g, ""); }; // a timestamp -> local "Mon Oct 12, 2:14 PM" const treFmtWhen = (iso) => { const d = new Date(iso || ""); if (isNaN(d.getTime())) return ""; return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }).replace(/,/g, "") + ", " + d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); }; const treFmtTime = (iso) => { const d = new Date(iso || ""); return isNaN(d.getTime()) ? "" : d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); }; const treAgo = (iso, ageMinutes) => { let min = Number(ageMinutes); if (!isFinite(min) || min <= 0) { const t = Date.parse(iso || ""); min = isNaN(t) ? 0 : Math.max(0, Math.round((Date.now() - t) / 60000)); } if (min < 1) return "just now"; if (min < 60) return min + " min ago"; if (min < 48 * 60) return Math.round(min / 60) + " h ago"; return Math.round(min / 1440) + " days ago"; }; const treVehicle = (t) => [t && t.year, t && t.make, t && t.model].filter((x) => x !== null && x !== undefined && String(x).trim() !== "").join(" ").toUpperCase() || (t && t.vin) || "Trade"; const treErrText = (e, fallback) => (e && e.body && e.body.error) || fallback || (e && e.message) || "Something went wrong. Try again."; const treFlagLabel = (k) => { const hit = TRE_COND_FLAGS.find((x) => x[0] === k); return hit ? hit[1] : k; }; const treUp = (s) => String(s == null ? "" : s).toUpperCase(); const treDigits = (s) => String(s == null ? "" : s).replace(/[^0-9]/g, ""); const treLsTake = (key) => { try { const v = localStorage.getItem(key); if (v != null) localStorage.removeItem(key); return v; } catch (e) { return null; } }; // ── feature detection: GET /api/sales-alerts/me (the UI's only source of feature flags) ── let treMeCache = { state: "unknown", me: null }; const treLoadMe = async () => { try { const r = await apiFetch("/sales-alerts/me"); treMeCache = (r && r.ok) ? { state: "on", me: r } : { state: "off", me: null }; } catch (e) { treMeCache = { state: "off", me: null }; } return treMeCache; }; const useTreMe = () => { // a cached answer belongs to the person who was signed in when it was fetched const fresh = () => { const who = (window.DMS && DMS.userId) ? String(DMS.userId() || "") : ""; return (treMeCache.me && treMeCache.me.user && String(treMeCache.me.user.id || "") !== who) ? { state: "unknown", me: null } : treMeCache; }; const [st, setSt] = React.useState(fresh); React.useEffect(() => { let alive = true; const run = () => treLoadMe().then((c) => { if (alive) setSt({ state: c.state, me: c.me }); }); run(); const off = (window.DMS && DMS.onConn) ? DMS.onConn((s) => { if (s === "online") run(); }) : null; return () => { alive = false; if (typeof off === "function") off(); }; }, []); return st; }; // ── photos: decoded in the browser and saved as JPEG (longest side 1600 px @0.8; > 350 KB -> 1280 @0.7; // > 440 KB -> 1024 @0.6; still bigger -> smaller again), plus a 320 px thumbnail @0.7 ── const treReadB64 = (blob) => new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(String(fr.result || "").replace(/^data:[^,]*,/, "")); fr.onerror = () => rej(fr.error || new Error("read failed")); fr.readAsDataURL(blob); }); const treLoadPicture = async (file) => { if (window.createImageBitmap) { try { return await createImageBitmap(file, { imageOrientation: "from-image" }); } catch (e) { /* fall back to */ } } return await new Promise((res, rej) => { const u = URL.createObjectURL(file); const im = new Image(); im.onload = () => { setTimeout(() => URL.revokeObjectURL(u), 2000); res(im); }; im.onerror = () => { URL.revokeObjectURL(u); rej(new Error("That file is not a picture.")); }; im.src = u; }); }; const treEncodeJpeg = (img, maxPx, quality) => new Promise((res, rej) => { const w0 = img.width || img.naturalWidth || 1, h0 = img.height || img.naturalHeight || 1; const k = Math.min(1, maxPx / Math.max(w0, h0)); const w = Math.max(1, Math.round(w0 * k)), h = Math.max(1, Math.round(h0 * k)); const c = document.createElement("canvas"); c.width = w; c.height = h; const g = c.getContext("2d"); g.fillStyle = "#FFFFFF"; g.fillRect(0, 0, w, h); g.drawImage(img, 0, 0, w, h); c.toBlob((b) => (b ? res({ blob: b, w, h }) : rej(new Error("The picture could not be prepared."))), "image/jpeg", quality); }); const treShrinkPhoto = async (file) => { const img = await treLoadPicture(file); let out = await treEncodeJpeg(img, 1600, 0.8); if (out.blob.size > 350000) out = await treEncodeJpeg(img, 1280, 0.7); if (out.blob.size > 440000) out = await treEncodeJpeg(img, 1024, 0.6); let px = 1024; while (out.blob.size > 440000 && px > 400) { px = Math.round(px * 0.8); out = await treEncodeJpeg(img, px, 0.55); } let th = await treEncodeJpeg(img, 320, 0.7); if (th.blob.size > 58000) th = await treEncodeJpeg(img, 320, 0.5); if (th.blob.size > 58000) th = await treEncodeJpeg(img, 220, 0.45); try { if (img.close) img.close(); } catch (e) {} return { data: await treReadB64(out.blob), size: out.blob.size, width: out.w, height: out.h, thumb: await treReadB64(th.blob), thumbSize: th.blob.size, url: URL.createObjectURL(th.blob) }; }; // Photo bytes -> object URL (cached per path while the screen is open; revoked when it closes). const treBlobCache = new Map(); const treBlobUrl = (path) => { if (!treBlobCache.has(path)) { const p = DMS.apiBlob(path).then((b) => URL.createObjectURL(b)); p.catch(() => treBlobCache.delete(path)); treBlobCache.set(path, p); } return treBlobCache.get(path); }; const treBlobClear = () => { treBlobCache.forEach((p) => { p.then((u) => { try { URL.revokeObjectURL(u); } catch (e) {} }).catch(() => {}); }); treBlobCache.clear(); }; const TreImg = ({ path, alt, style, onClick, className }) => { const [src, setSrc] = React.useState(null); const [err, setErr] = React.useState(false); React.useEffect(() => { let alive = true; setSrc(null); setErr(false); treBlobUrl(path).then((u) => { if (alive) setSrc(u); }).catch(() => { if (alive) setErr(true); }); return () => { alive = false; }; }, [path]); if (err) return no picture; if (!src) return ; return {alt; }; // In-page photo viewer (never a new window / tab / iframe). const TrePhotoViewer = ({ tradeId, photos, index, onClose }) => { const [i, setI] = React.useState(index || 0); React.useEffect(() => { const k = (e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); } else if (e.key === "ArrowRight") setI((x) => Math.min(photos.length - 1, x + 1)); else if (e.key === "ArrowLeft") setI((x) => Math.max(0, x - 1)); }; document.addEventListener("keydown", k, true); return () => document.removeEventListener("keydown", k, true); }, [photos.length]); const p = photos[i]; if (!p) return null; return (
e.stopPropagation()} style={{ display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 13 }}> Photo {i + 1} of {photos.length}{p.label ? " — " + p.label : ""}
e.stopPropagation()} style={{ maxWidth: "100%", maxHeight: "calc(100% - 60px)", display: "flex" }}>
); }; // Thumbnails of one trade (+ add / remove photos where the server allows it). const TrePhotoStrip = ({ trade, canAdd, canDelete, onChanged, galleryClass }) => { const [viewAt, setViewAt] = React.useState(-1); const [busy, setBusy] = React.useState(""); const [err, setErr] = React.useState(""); const inputRef = React.useRef(null); const photos = (trade.photos || []).slice().sort((a, b) => a.seq - b.seq); const room = TRE_MAX_PHOTOS - photos.length; const add = async (files) => { const list = Array.from(files || []).slice(0, Math.max(0, room)); if (!list.length) { setErr(`A trade can have ${TRE_MAX_PHOTOS} photos.`); return; } setErr(""); for (let k = 0; k < list.length; k++) { setBusy(`Uploading photo ${k + 1} of ${list.length}…`); try { const p = await treShrinkPhoto(list[k]); try { URL.revokeObjectURL(p.url); } catch (e) {} await apiFetch("/trades/" + encodeURIComponent(trade.id) + "/photos", { method: "POST", body: { label: "Other", mime: "image/jpeg", data: p.data, thumb: p.thumb, thumbMime: "image/jpeg" } }); } catch (e) { setErr(treErrText(e, `Photo ${k + 1} could not be sent. Try again.`)); break; } } setBusy(""); if (inputRef.current) inputRef.current.value = ""; onChanged && onChanged(); }; const del = async (p) => { setErr(""); setBusy("Removing photo…"); try { await apiFetch("/trades/" + encodeURIComponent(trade.id) + "/photos/" + encodeURIComponent(p.id), { method: "DELETE" }); } catch (e) { setErr(treErrText(e, "That photo could not be removed.")); } setBusy(""); onChanged && onChanged(); }; return (
{photos.length === 0 && No photos.} {photos.map((p, i) => (
setViewAt(i)} style={{ width: 120, height: 84, objectFit: "cover", borderRadius: 5, border: "1px solid var(--jb-border)", cursor: "zoom-in" }} /> {p.label || "Photo " + p.seq} {canDelete && }
))}
{canAdd && room > 0 && (
)} {busy &&
{busy}
} {err &&
{err}
} {viewAt >= 0 && setViewAt(-1)} />}
); }; // ── shared styles ─────────────────────────────────────────────────────────── const treLbl = { fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--jb-text-muted)", display: "block", marginBottom: 3 }; const treInp = { width: "100%", boxSizing: "border-box", fontSize: 13, padding: "6px 8px", border: "1px solid #C8CCD2", borderRadius: 4, fontFamily: "var(--jb-font)", background: "#FFF", color: "var(--jb-text)" }; const treCard = { background: "#FFF", border: "1px solid var(--jb-border)", borderRadius: "var(--jb-r, 8px)", padding: "12px 14px" }; const treH = { fontSize: 13, fontWeight: 800, color: "var(--jb-navy)", marginBottom: 8, textTransform: "uppercase", letterSpacing: "0.03em" }; const treMsgStyle = (tone) => ({ fontSize: 12.5, padding: "6px 10px", borderRadius: 5, marginTop: 8, background: tone === "err" ? "#FBE9E7" : tone === "ok" ? "#E6F4EA" : "#EEF4FB", color: tone === "err" ? "#B23A2E" : tone === "ok" ? "#0E7A37" : "var(--jb-navy)" }); // Live refresh: the server pushes {type:'sales_trade'} over the WebSocket (DMSCore hands unknown message // types to the page as a `k2b-ws` event) — plus a 60 s poll while the screen is open. const useTreLive = (reload) => { const ref = React.useRef(reload); ref.current = reload; React.useEffect(() => { const onWs = (e) => { const m = e && e.detail; if (m && m.type === "sales_trade") ref.current && ref.current(m); }; window.addEventListener("k2b-ws", onWs); const t = setInterval(() => ref.current && ref.current(null), 60000); const onFocus = () => ref.current && ref.current(null); window.addEventListener("focus", onFocus); return () => { window.removeEventListener("k2b-ws", onWs); window.removeEventListener("focus", onFocus); clearInterval(t); }; }, []); }; /* ══════════════════════════════════════════════════════════════════════════ Salesperson view ══════════════════════════════════════════════════════════════════════════ */ const treBlankForm = () => ({ vin: "", year: "", make: "", model: "", trim: "", miles: "", color: "", overall: "", flags: {}, notes: "", payoff: "", lender: "" }); const TreSalesView = ({ me, go }) => { const [f, setF] = React.useState(treBlankForm); const set = (k, v) => setF((s) => Object.assign({}, s, { [k]: v })); const [dec, setDec] = React.useState({ state: "none" }); // none | busy | ok | no const [needConfirm, setNeedConfirm] = React.useState(false); const [vinOk, setVinOk] = React.useState(false); const [lead, setLead] = React.useState(null); // {leadId, name, custNum} const [cust, setCust] = React.useState(null); // {custNum, name, phone} const [custQ, setCustQ] = React.useState(""); const [photos, setPhotos] = React.useState([]); // [{key, label, data, thumb, size, width, height, url}] const [prepping, setPrepping] = React.useState(""); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(null); // {text, tone} const [pending, setPending] = React.useState(null); // {tradeId, vehicle, photos:[{..., state, err}]} const [trades, setTrades] = React.useState([]); const [listErr, setListErr] = React.useState(""); const [loaded, setLoaded] = React.useState(false); const [openPhotos, setOpenPhotos] = React.useState({}); const [confirmW, setConfirmW] = React.useState(""); const [focusId, setFocusId] = React.useState(null); const fileRef = React.useRef(null); const keySeq = React.useRef(1); const load = async () => { try { const r = await apiFetch("/trades?status=all&limit=100"); setTrades((r && r.trades) || []); setListErr(""); } catch (e) { setListErr(treErrText(e, "Your trades could not be loaded right now.")); } setLoaded(true); }; React.useEffect(() => { load(); }, []); useTreLive(() => load()); // hand-offs: CRM prefill (jb_trade_lead) and "open this trade" (jb_trade_focus) React.useEffect(() => { const raw = treLsTake("jb_trade_lead"); if (raw) { let p = null; try { p = JSON.parse(raw); } catch (e) {} if (p && typeof p === "object") { if (p.leadId) setLead({ leadId: String(p.leadId), name: String(p.name || ""), custNum: String(p.custNum || "") }); setF((s) => Object.assign({}, s, { vin: treNormVin(p.vin), miles: treDigits(p.miles), year: treDigits(p.year).slice(0, 4), make: treUp(p.make), model: treUp(p.model), trim: treUp(p.trim), color: String(p.color == null ? "" : p.color), // the VIN Decode screen hands these over too payoff: +p.payoff > 0 ? String(+p.payoff) : "", lender: treUp(p.lender) })); if (p.custNum) { const c = (DMS.customers && DMS.customers()[p.custNum]) || null; setCust({ custNum: String(p.custNum), name: String(p.name || (c && [c.lastName, c.firstName].filter(Boolean).join(", ")) || p.custNum), phone: (c && c.phone) || "" }); } } } const fid = treLsTake("jb_trade_focus"); if (fid) setFocusId(fid); }, []); React.useEffect(() => { if (!focusId || !trades.length) return; const el = document.querySelector('.tre-row[data-trade-id="' + String(focusId).replace(/"/g, "") + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: "center" }); }, [focusId, trades.length]); const vinNow = treNormVin(f.vin); const decoded = dec.state === "ok" && dec.vin === vinNow; const changeVin = (v) => { const nv = treNormVin(v).slice(0, 17); set("vin", nv); if (nv !== dec.vin) { setDec({ state: "none" }); setNeedConfirm(false); setVinOk(false); } }; const lookUp = async () => { setMsg(null); if (!treIsVin(vinNow)) { setMsg({ text: "A VIN has 17 letters and numbers (the letters I, O and Q are never used).", tone: "err" }); return; } setDec({ state: "busy", vin: vinNow }); try { const r = await apiFetch("/trades/decode", { method: "POST", body: { vin: vinNow } }); const v = (r && r.vehicle) || {}; if (r && r.decoded) { setF((s) => Object.assign({}, s, { year: v.year ? String(v.year) : "", make: treUp(v.make), model: treUp(v.model), trim: treUp(v.trim), color: s.color || String(v.exteriorColor || "") })); const text = [[v.year, treUp(v.make), treUp(v.model), treUp(v.trim)].filter(Boolean).join(" "), v.engine, v.drivetrain].filter(Boolean).join(", "); setDec({ state: "ok", vin: vinNow, text }); setNeedConfirm(false); } else { // a partial decode (the server read the year / make from the VIN itself): fill only what is still blank, the rest is typed if (r && r.partial && (v.year || v.make)) setF((s) => Object.assign({}, s, { year: s.year || (v.year ? String(v.year) : ""), make: s.make || treUp(v.make) })); setDec({ state: "no", vin: vinNow, text: (r && r.note) || "We could not find this VIN. Type the year, make and model." }); } } catch (e) { const code = e && e.body && e.body.code; if (code === "bad_check_digit") { setNeedConfirm(true); setDec({ state: "no", vin: vinNow, text: treErrText(e) }); } else setDec({ state: "no", vin: vinNow, text: treErrText(e, "VIN look-up is not available right now. Type the year, make and model.") }); } }; // customers for the picker (the RO editor's search: name, phone, email, customer #) const custRows = React.useMemo(() => (custQ.trim().length >= 2 && DMS.customerSearch ? DMS.customerSearch(custQ).slice(0, 8) : []), [custQ]); const addFiles = async (files) => { const list = Array.from(files || []); if (fileRef.current) fileRef.current.value = ""; if (!list.length) return; const room = TRE_MAX_PHOTOS - photos.length; if (room <= 0) { setMsg({ text: `A trade can have ${TRE_MAX_PHOTOS} photos.`, tone: "err" }); return; } const take = list.slice(0, room); const out = []; for (let k = 0; k < take.length; k++) { setPrepping(`Preparing photo ${k + 1} of ${take.length}…`); try { const p = await treShrinkPhoto(take[k]); const n = photos.length + out.length; out.push(Object.assign(p, { key: "p" + (keySeq.current++), label: TRE_PHOTO_LABELS[Math.min(n, TRE_PHOTO_LABELS.length - 1)] })); } catch (e) { setMsg({ text: `Photo ${k + 1} is not a picture this screen can read. Take it again.`, tone: "err" }); } } setPrepping(""); if (list.length > room) setMsg({ text: `Only ${TRE_MAX_PHOTOS} photos fit on a trade — the first ${room} were added.`, tone: "warn" }); setPhotos((s) => s.concat(out)); }; const removePhoto = (key) => setPhotos((s) => s.filter((p) => { if (p.key === key) { try { URL.revokeObjectURL(p.url); } catch (e) {} } return p.key !== key; })); const uploadAll = async (tradeId, list) => { const res = list.map((p) => Object.assign({}, p, { state: "waiting", err: "" })); for (let k = 0; k < res.length; k++) { setMsg({ text: `Trade sent. Uploading photo ${k + 1} of ${res.length}…`, tone: "info" }); try { await apiFetch("/trades/" + encodeURIComponent(tradeId) + "/photos", { method: "POST", body: { label: res[k].label || "Other", mime: "image/jpeg", data: res[k].data, thumb: res[k].thumb, thumbMime: "image/jpeg" } }); res[k].state = "sent"; } catch (e) { const st = e && e.status; res[k].state = "failed"; res[k].err = (st === 409 || st === 413 || st === 415 || st === 429) ? treErrText(e) : `Photo ${k + 1} could not be sent. Try again.`; } } return res; }; const submit = async () => { setMsg(null); if (!treIsVin(vinNow)) { setMsg({ text: "Type the 17-character VIN.", tone: "err" }); return; } const miles = treDigits(f.miles); if (!miles) { setMsg({ text: "Type the miles on the odometer.", tone: "err" }); return; } if (!f.overall) { setMsg({ text: "Pick the overall condition: Excellent, Good, Fair or Rough.", tone: "err" }); return; } if (needConfirm && !vinOk) { setMsg({ text: "That VIN does not pass the VIN check. Check it against the windshield plate; if it is right, tick 'VIN is correct'.", tone: "err" }); return; } const body = { vin: vinNow, miles: parseInt(miles, 10), color: f.color.trim() || undefined, year: f.year ? parseInt(f.year, 10) : undefined, make: f.make.trim() || undefined, model: f.model.trim() || undefined, trim: f.trim.trim() || undefined, condition: { overall: f.overall, flags: TRE_COND_FLAGS.map((x) => x[0]).filter((k) => f.flags[k]), notes: f.notes.trim() }, payoff: treDigits(f.payoff.split(".")[0]) ? Number(String(f.payoff).replace(/[^0-9.]/g, "")) : undefined, payoffLender: f.lender.trim() || undefined, custNum: cust ? cust.custNum : undefined, custName: cust ? cust.name : (lead ? lead.name : undefined), leadId: lead ? lead.leadId : undefined, }; if (needConfirm && vinOk) body.vinConfirmed = true; setBusy(true); let trade = null; try { const r = await apiFetch("/trades", { method: "POST", body }); trade = r && r.trade; } catch (e) { const code = e && e.body && e.body.code; if (code === "bad_check_digit") setNeedConfirm(true); setMsg({ text: treErrText(e, "The trade could not be sent. Try again."), tone: "err" }); setBusy(false); return; } let sentPhotos = []; if (trade && photos.length) sentPhotos = await uploadAll(trade.id, photos); const failed = sentPhotos.filter((p) => p.state === "failed"); sentPhotos.filter((p) => p.state === "sent").forEach((p) => { try { URL.revokeObjectURL(p.url); } catch (e) {} }); setPending(failed.length ? { tradeId: trade.id, vehicle: treVehicle(trade), photos: failed } : null); setMsg({ text: failed.length ? `Trade sent to a manager, but ${failed.length} photo${failed.length === 1 ? "" : "s"} did not go through — see below.` : `Trade sent to a manager${sentPhotos.length ? " with " + sentPhotos.length + " photo" + (sentPhotos.length === 1 ? "" : "s") : ""}. You will get an alert when the offer is ready.`, tone: failed.length ? "err" : "ok" }); setF(treBlankForm()); setDec({ state: "none" }); setNeedConfirm(false); setVinOk(false); setPhotos([]); setLead(null); setCust(null); setCustQ(""); setBusy(false); setFocusId(trade ? trade.id : null); load(); }; const retryPhoto = async (key) => { if (!pending) return; const p = pending.photos.find((x) => x.key === key); if (!p) return; const res = await uploadAll(pending.tradeId, [p]); const left = pending.photos.map((x) => (x.key === key ? res[0] : x)).filter((x) => x.state !== "sent"); if (res[0].state === "sent") { try { URL.revokeObjectURL(p.url); } catch (e) {} } setPending(left.length ? Object.assign({}, pending, { photos: left }) : null); setMsg(left.length ? { text: `${left.length} photo${left.length === 1 ? "" : "s"} still not sent.`, tone: "err" } : { text: "All photos are sent.", tone: "ok" }); load(); }; const withdraw = async (t) => { setConfirmW(""); try { await apiFetch("/trades/" + encodeURIComponent(t.id) + "/withdraw", { method: "POST", body: {} }); setMsg({ text: `${treVehicle(t)} was withdrawn.`, tone: "ok" }); } catch (e) { setMsg({ text: treErrText(e, "The trade could not be withdrawn."), tone: "err" }); } load(); }; const roVeh = decoded; // decoded vehicle fields are read-only (they came from the VIN) return (
{/* ── send a trade ── */}
Send a trade for a value
A manager looks at the trade and sends you the offer to present to the customer. You will get an alert.
{lead && (
For lead {lead.name || lead.leadId} — the offer will also show on that lead in the CRM.
setLead(null)}>not for this lead
)}
changeVin(e.target.value)} placeholder="17 characters, from the windshield or door" maxLength={20} style={{ ...treInp, flex: "1 1 auto", minWidth: 0, fontFamily: "var(--jb-mono)", letterSpacing: "0.04em" }} />
{dec.vin === vinNow && dec.state === "ok" &&
✓ {dec.text}
} {dec.vin === vinNow && dec.state === "no" &&
{dec.text}
} {needConfirm && ( )}
set("year", treDigits(e.target.value).slice(0, 4))} style={{ ...treInp, background: roVeh ? "#F0F2F5" : "#FFF" }} />
set("make", treUp(e.target.value))} style={{ ...treInp, background: roVeh ? "#F0F2F5" : "#FFF" }} />
set("model", treUp(e.target.value))} style={{ ...treInp, background: roVeh ? "#F0F2F5" : "#FFF" }} />
set("trim", treUp(e.target.value))} style={{ ...treInp, background: roVeh ? "#F0F2F5" : "#FFF" }} />
set("miles", treDigits(e.target.value).slice(0, 6))} placeholder="odometer" style={{ ...treInp, fontFamily: "var(--jb-mono)" }} />
set("color", e.target.value)} style={treInp} />
{TRE_COND_FLAGS.map((x) => ( ))}