<div id="wds-driver-picklist" style="max-width:900px;margin:0 auto;padding:16px;font-family:-apple-system,system-ui,sans-serif;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;">
<div>
<div style="font-size:20px;font-weight:700;">WDS Driver Picklist</div>
<div id="wds-env" style="opacity:.7;font-size:12px;margin-top:4px;"></div>
<div id="wds-order" style="opacity:.85;font-size:12px;margin-top:4px;"></div>
<div id="wds-token" style="opacity:.7;font-size:12px;margin-top:4px;"></div>
</div>
<div style="border:1px solid #ddd;border-radius:12px;padding:10px 12px;font-size:12px;min-width:220px;">
<div><strong>Status</strong></div>
<div id="wds-status" style="opacity:.8;margin-top:4px;"></div>
</div>
</div>
<div id="wds-order-input" style="margin-top:14px;display:none;border:1px solid #ddd;border-radius:12px;padding:12px;">
<div style="font-weight:700;margin-bottom:8px;">Enter orderId + token</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<input id="wds-orderId" style="padding:10px;border:1px solid #ddd;border-radius:10px;width:360px;max-width:100%;" placeholder="Paste orderId here" />
<input id="wds-tokenInput" style="padding:10px;border:1px solid #ddd;border-radius:10px;width:360px;max-width:100%;" placeholder="Paste token here" />
<button id="wds-load" style="padding:10px 12px;border-radius:10px;border:1px solid #111;background:#fff;cursor:pointer;">Load Picklist</button>
</div>
<div style="opacity:.7;font-size:12px;margin-top:8px;">
Tip: open the driver link generated from the client shopping list page. It includes ?orderId=...&token=...
</div>
</div>
<div style="margin-top:14px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<button id="wds-refresh" style="padding:10px 12px;border-radius:10px;border:1px solid #111;background:#fff;cursor:pointer;">Refresh</button>
<button id="wds-undo" style="padding:10px 12px;border-radius:10px;border:1px solid #111;background:#fff;cursor:pointer;">Undo Last</button>
<span style="font-size:12px;opacity:.75;">Store locationId:</span>
<strong id="wds-loc" style="font-size:12px;"></strong>
</div>
<div id="wds-list" style="margin-top:14px;"></div>
<div id="wds-scanner" style="display:none;border:1px solid #ddd;border-radius:12px;padding:12px;margin-top:14px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
<div style="font-weight:700;">Scanner <span id="wds-scan-item" style="font-weight:400;opacity:.7;"></span></div>
<button id="wds-close-scan" style="padding:8px 10px;border-radius:10px;border:1px solid #bbb;background:#fff;cursor:pointer;">Close</button>
</div>
<video id="wds-video" playsinline autoplay muted
style="width:100%;max-height:420px;object-fit:cover;border-radius:12px;border:1px solid #eee;margin-top:10px;">
</video>
<div style="opacity:.7;font-size:12px;margin-top:8px;">Point the camera at the barcode. It will auto-submit.</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js"></script>
<script>
(() => {
// ===== CONFIG =====
const API_BASE = "https://wds-bsd-api.onrender.com";
const ACCEPTED_REFRESH_DELAY_MS = 600;
const SCANNER_VERSION = "1.3";
const SUB_PRICE_WARN_PCT = 0.25;
const SUB_MIN_NAME_MATCH_SCORE = 1;
// ==================
const envEl = document.getElementById("wds-env");
const orderEl = document.getElementById("wds-order");
const tokenEl = document.getElementById("wds-token");
const statusEl = document.getElementById("wds-status");
const locEl = document.getElementById("wds-loc");
const listEl = document.getElementById("wds-list");
const refreshBtn = document.getElementById("wds-refresh");
const undoBtn = document.getElementById("wds-undo");
const orderInputWrap = document.getElementById("wds-order-input");
const orderIdInput = document.getElementById("wds-orderId");
const tokenInput = document.getElementById("wds-tokenInput");
const loadBtn = document.getElementById("wds-load");
const scannerWrap = document.getElementById("wds-scanner");
const closeScanBtn = document.getElementById("wds-close-scan");
const scanItemEl = document.getElementById("wds-scan-item");
const videoEl = document.getElementById("wds-video");
envEl.textContent = API_BASE + " • Scanner " + SCANNER_VERSION;
const esc = s => String(s ?? "").replace(/[&<>"']/g, m => ({({
"&":"&",
"<":"<",
">":">",
'"':""",
"'":"'"
}[m]));
const setStatus = msg => statusEl.textContent = msg || "";
function getParam(name) {
const u = new URL(window.location.href);
return u.searchParams.get(name);
}
function setUrlParams(orderId, token) {
const u = new URL(window.location.href);
if (orderId) u.searchParams.set("orderId", orderId);
if (token) u.searchParams.set("token", token);
window.history.replaceState({}, "", u.toString());
}
let busy = false;
let scanLock = false;
let refreshTimer = null;
async function fetchWithRetry(url, options = {}, tries = 3) {
let lastErr;
for (let i = 0; i < tries; i++) {
try {
const r = await fetch(url, options);
if (!r.ok) throw new Error(await r.text());
return r;
} catch (e) {
lastErr = e;
await new Promise(res => setTimeout(res, 300 * Math.pow(3, i)));
}
}
throw lastErr;
}
async function apiGet(path) {
const r = await fetchWithRetry(API_BASE + path, { method: "GET" }, 3);
return r.json();
}
async function apiPost(path, body) {
const r = await fetchWithRetry(API_BASE + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {})
}, 3);
return r.json();
}
function digitsOnly(s) {
return String(s || "").replace(/\D/g, "");
}
function upcCheckDigit(d11) {
let odd = 0;
let even = 0;
for (let i = 0; i < 11; i++) {
const n = parseInt(d11[i], 10);
if (i % 2 === 0) odd += n;
else even += n;
}
const total = odd * 3 + even;
return String((10 - (total % 10)) % 10);
}
function normalizeSet(code) {
const d = digitsOnly(code);
if (!d) return new Set();
const out = new Set([d]);
[14, 13, 12, 11].forEach(n => {
if (d.length > n) out.add(d.slice(-n));
});
if (d.length >= 11) {
out.add(d.slice(0, 11));
out.add(d.slice(-11));
}
if (d.length === 12) {
out.add("0" + d);
out.add(d.slice(0, 11));
}
if (d.length === 13 && d.startsWith("0")) {
out.add(d.slice(1));
out.add(d.slice(1, 12));
}
if (d.length === 11) {
const chk = upcCheckDigit(d);
const upc12 = d + chk;
out.add(upc12);
out.add("0" + upc12);
}
return out;
}
function codesMatchLocal(scanned, expected) {
const a = normalizeSet(scanned);
const b = normalizeSet(expected);
for (const x of a) if (b.has(x)) return true;
return false;
}
function itemMeta(item) {
return item?.item_meta_json || item?.item_meta || {};
}
function normalizedText(v) {
return String(v || "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function hasOrganicText(v) {
return /\borganic\b/i.test(String(v || ""));
}
function organicStateFromItem(item) {
const meta = itemMeta(item);
const combined = [
item?.name,
item?.brand,
item?.size,
meta.brand,
meta.size,
meta.notes,
meta.itemDepartment,
meta.substitutionNotes
].filter(Boolean).join(" ");
return hasOrganicText(combined) ? "organic" : "not_organic";
}
function extractSizeInfo(text) {
const s = String(text || "").toLowerCase();
const match = s.match(/(\d+(?:\.\d+)?)\s?(fl oz|oz|ounce|ounces|lb|lbs|pound|pounds|ct|count|pk|pack|gal|gallon|ml|l)\b/);
if (!match) return null;
let value = Number(match[1]);
let unit = match[2];
if (!Number.isFinite(value)) return null;
unit = unit
.replace("ounces", "oz")
.replace("ounce", "oz")
.replace("lbs", "lb")
.replace("pounds", "lb")
.replace("pound", "lb")
.replace("count", "ct")
.replace("pack", "pk")
.replace("gallon", "gal");
return { value, unit };
}
function sizeCloseEnough(originalSize, substituteSize) {
if (!originalSize || !substituteSize) {
return {
ok: true,
warning: "Size could not be fully verified."
};
}
if (originalSize.unit !== substituteSize.unit) {
return {
ok: false,
warning: `Size unit differs: original ${originalSize.value} ${originalSize.unit}, substitute ${substituteSize.value} ${substituteSize.unit}.`
};
}
const ratio = substituteSize.value / originalSize.value;
if (ratio >= 0.80 && ratio <= 1.25) {
return { ok: true, warning: "" };
}
return {
ok: false,
warning: `Size differs more than expected: original ${originalSize.value} ${originalSize.unit}, substitute ${substituteSize.value} ${substituteSize.unit}.`
};
}
function productKeywords(name) {
const stop = new Set([
"the", "and", "or", "with", "for", "each", "fresh", "kroger",
"simple", "truth", "private", "selection", "organic", "original",
"brand", "pack", "ct", "oz", "lb"
]);
return normalizedText(name)
.split(" ")
.filter(w => w.length >= 3 && !stop.has(w))
.slice(0, 8);
}
function nameMatchScore(originalName, substituteName) {
const a = productKeywords(originalName);
const b = new Set(productKeywords(substituteName));
return a.reduce((sum, word) => sum + (b.has(word) ? 1 : 0), 0);
}
function priceWarning(originalItem, substituteProduct) {
const meta = itemMeta(originalItem);
const originalPrice =
Number(meta.effectivePrice ?? meta.unitPrice ?? meta.regularPrice ?? originalItem.effectivePrice ?? originalItem.unitPrice);
const substitutePrice =
Number(substituteProduct?.effectivePrice ?? substituteProduct?.price?.effective ?? substituteProduct?.regularPrice);
if (!Number.isFinite(originalPrice) || !Number.isFinite(substitutePrice) || originalPrice <= 0) {
return "";
}
const pctDiff = (substitutePrice - originalPrice) / originalPrice;
if (pctDiff > SUB_PRICE_WARN_PCT) {
return `Substitute appears more than ${Math.round(SUB_PRICE_WARN_PCT * 100)}% higher in price.`;
}
return "";
}
function evaluateSubstitution(originalItem, substituteProduct, substituteQty = 1) {
const meta = itemMeta(originalItem);
const originalName = originalItem?.name || "";
const substituteName = substituteProduct?.name || substituteProduct?.description || "Substitute item";
const warnings = [];
const approvals = [];
const score = nameMatchScore(originalName, substituteName);
if (score < SUB_MIN_NAME_MATCH_SCORE) {
warnings.push("Substitute may not be the same type of item.");
approvals.push("type_check");
}
const originalOrganic = organicStateFromItem(originalItem);
const substituteOrganic = hasOrganicText([
substituteName,
substituteProduct?.brand,
substituteProduct?.size,
Array.isArray(substituteProduct?.categories) ? substituteProduct.categories.join(" ") : ""
].filter(Boolean).join(" ")) ? "organic" : "not_organic";
if (originalOrganic === "organic" && substituteOrganic !== "organic") {
warnings.push("Original item appears organic, but substitute does not appear organic.");
approvals.push("organic_downgrade");
}
if (originalOrganic !== "organic" && substituteOrganic === "organic") {
warnings.push("Substitute appears organic while original was not organic. Usually acceptable, but may cost more.");
}
const originalSize = extractSizeInfo([originalItem?.size, meta.size, originalName].filter(Boolean).join(" "));
const substituteSize = extractSizeInfo([substituteProduct?.size, substituteName].filter(Boolean).join(" "));
const sizeCheck = sizeCloseEnough(originalSize, substituteSize);
if (sizeCheck.warning) {
warnings.push(sizeCheck.warning);
if (!sizeCheck.ok) approvals.push("size_difference");
}
const pWarn = priceWarning(originalItem, substituteProduct);
if (pWarn) {
warnings.push(pWarn);
approvals.push("price_increase");
}
const originalQty = Math.max(1, parseInt(originalItem?.qty_required || originalItem?.qty || 1, 10));
const pickedQty = Math.max(1, parseInt(substituteQty || 1, 10));
if (pickedQty < originalQty) {
warnings.push(`Substitute quantity ${pickedQty} is less than original requested quantity ${originalQty}.`);
approvals.push("quantity_short");
}
return {
ok: approvals.length === 0,
needsApproval: approvals.length > 0,
warnings,
approvals,
score,
originalOrganic,
substituteOrganic,
originalSize,
substituteSize,
substituteName
};
}
async function lookupKrogerProductByUpc(upc) {
const code = digitsOnly(upc);
const locationIdNow = picklist?.locationId || locEl.textContent || "";
if (!code || !locationIdNow) return null;
try {
const json = await apiGet(`/v1/kroger/products?term=${encodeURIComponent(code)}&locationId=${encodeURIComponent(locationIdNow)}&limit=5`);
const data = Array.isArray(json?.data) ? json.data : [];
return data[0] || null;
} catch (e) {
return null;
}
}
function buildSubstitutionNotes(originalItem, substituteProduct, scannedCode, evaluation, substituteQty) {
const warnings = evaluation?.warnings?.length
? evaluation.warnings.join(" | ")
: "No substitution warnings.";
return [
"Substitute scanned.",
`Original: ${originalItem?.name || ""}`,
`Substitute: ${substituteProduct?.name || substituteProduct?.description || "Unknown item"}`,
`Substitute UPC: ${scannedCode || ""}`,
`Substitute Qty: ${substituteQty || 1}`,
`Validation: ${evaluation?.ok ? "accepted" : "needs review"}`,
`Warnings: ${warnings}`
].join(" ");
}
let orderId = getParam("orderId");
let token = getParam("token");
let picklist = null;
const ZXing = window.ZXing;
const reader = new ZXing.BrowserMultiFormatReader();
let scanTarget = null;
function tokenQS() {
return "token=" + encodeURIComponent(token || "");
}
function picklistPath() {
return `/v1/orders/${encodeURIComponent(orderId)}/picklist?${tokenQS()}&_=${Date.now()}`;
}
function requireTokenUI() {
if (!orderId || !token) {
orderInputWrap.style.display = "block";
listEl.innerHTML = '<div style="opacity:.7;">Missing orderId or token. Enter both above.</div>';
setStatus("");
return true;
}
orderInputWrap.style.display = "none";
return false;
}
async function loadPicklist() {
if (busy) return;
if (requireTokenUI()) return;
busy = true;
refreshBtn.disabled = true;
undoBtn.disabled = true;
orderEl.innerHTML = `<strong>orderId:</strong> ${esc(orderId)}`;
tokenEl.innerHTML = `<strong>token:</strong> ${esc(token).slice(0, 8)}…`;
try {
setStatus("Loading picklist…");
picklist = await apiGet(picklistPath());
locEl.textContent = picklist.locationId || "";
renderPicklist();
setStatus("");
} catch (e) {
setStatus("Error: " + e.message);
listEl.innerHTML = '<div style="color:#b00;">Failed to load picklist.</div>';
} finally {
busy = false;
refreshBtn.disabled = false;
undoBtn.disabled = false;
}
}
async function refreshPicklistDirect(statusAfter = "") {
if (requireTokenUI()) return;
picklist = await apiGet(picklistPath());
locEl.textContent = picklist.locationId || "";
renderPicklist();
if (statusAfter) setStatus(statusAfter);
}
async function saveValidatedSubstitution(originalItem, scannedCode, substituteProduct, substituteQty) {
const evaluation = evaluateSubstitution(originalItem, substituteProduct || {}, substituteQty);
const substituteName =
substituteProduct?.name ||
substituteProduct?.description ||
prompt("Substitute item name:", "") ||
"Substitute item";
const warningsText = evaluation.warnings.length
? evaluation.warnings.join("\n")
: "No major substitution warnings.";
const okToSave = confirm(
`Substitution Check\n\n` +
`Original: ${originalItem?.name || ""}\n` +
`Substitute: ${substituteName}\n` +
`UPC: ${scannedCode || ""}\n` +
`Qty: ${substituteQty || 1}\n\n` +
`${warningsText}\n\n` +
`Save this substitute?`
);
if (!okToSave) {
setStatus("Substitution cancelled.");
return;
}
const notes = buildSubstitutionNotes(
originalItem,
{ ...substituteProduct, name: substituteName },
scannedCode,
evaluation,
substituteQty
);
await apiPost(
`/v1/orders/${encodeURIComponent(orderId)}/items/${encodeURIComponent(originalItem.order_item_id)}/substitution?${tokenQS()}`,
{
unavailable: true,
substitution_name: substituteName,
substitution_upc: scannedCode || null,
substitution_notes: notes
}
);
await refreshPicklistDirect(
evaluation.ok
? "Substitution saved."
: "Substitution saved with warnings."
);
}
function scheduleAcceptedRefresh() {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
setStatus("Scan accepted. Updating picklist…");
refreshTimer = setTimeout(async () => {
try {
await refreshPicklistDirect("Picklist updated.");
} catch (e) {
setStatus("Scan accepted, but refresh failed. Tap Refresh.");
} finally {
refreshTimer = null;
}
}, ACCEPTED_REFRESH_DELAY_MS);
}
function remainingQtyForItem(item) {
const required = parseInt(item?.qty_required || 1, 10) || 1;
const picked = parseInt(item?.qty_picked || 0, 10) || 0;
return Math.max(0, required - picked);
}
async function saveShortageOrAlternate(target, pickedQty, remainingQty) {
const shortageQty = Math.max(0, remainingQty - pickedQty);
const wantsAlternate = confirm(
`You picked ${pickedQty} of ${remainingQty}. ` +
`There are ${shortageQty} unit(s) short.\n\n` +
`Is an alternate/substitute needed?\n\n` +
`OK = Add alternate/substitution notes\n` +
`Cancel = No alternate right now`
);
if (!wantsAlternate) {
await apiPost(
`/v1/orders/${encodeURIComponent(orderId)}/items/${encodeURIComponent(target.order_item_id)}/substitution?${tokenQS()}`,
{
unavailable: false,
substitution_name: null,
substitution_upc: null,
substitution_notes: `Short ${shortageQty} unit(s). Picked ${pickedQty} of ${remainingQty}. No alternate selected yet.`
}
);
return;
}
await startSubstituteScanner(target);
}
async function confirmPickedQuantity(target) {
const remaining = Math.max(1, parseInt(target.remaining_qty || 1, 10) || 1);
if (remaining <= 1) return 1;
const raw = prompt(
`${target.name || "This item"} needs ${remaining} unit(s).\n\n` +
`How many correct units did you pick?`,
String(remaining)
);
if (raw === null) {
setStatus("Scan cancelled before quantity confirmation.");
return null;
}
let pickedQty = parseInt(raw, 10);
if (!Number.isFinite(pickedQty) || pickedQty < 1) {
pickedQty = 1;
}
pickedQty = Math.min(pickedQty, remaining);
if (pickedQty < remaining) {
const moreAvailable = confirm(
`You entered ${pickedQty} of ${remaining}.\n\n` +
`Are the remaining ${remaining - pickedQty} exact unit(s) available to pick later?\n\n` +
`OK = Yes, leave item open\n` +
`Cancel = No, request alternate/substitution`
);
if (!moreAvailable) {
await saveShortageOrAlternate(target, pickedQty, remaining);
}
}
return pickedQty;
}
function renderPicklist() {
const items = (picklist && picklist.items) ? picklist.items : [];
listEl.innerHTML = "";
if (!items.length) {
listEl.innerHTML = '<div style="opacity:.7;">No items in this order.</div>';
return;
}
items.forEach(item => {
const remainingQty = remainingQtyForItem(item);
const done = remainingQty <= 0;
const unavailable = !!item.unavailable;
const meta = itemMeta(item);
const substitutionsAllowed = meta.allowSubstitutions !== false;
const row = document.createElement("div");
row.style.border = "1px solid #ddd";
row.style.borderRadius = "12px";
row.style.padding = "12px";
row.style.marginBottom = "10px";
row.style.opacity = done ? "0.75" : "1.0";
row.innerHTML = `
<div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;">
<div style="min-width:240px;flex:1;">
<div style="font-weight:700;">${esc(item.name)}</div>
<div style="font-size:12px;opacity:.75;margin-top:4px;">
<div><strong>Picked:</strong> ${esc(item.qty_picked)}/${esc(item.qty_required)} ${done ? "✅" : ""}</div>
${remainingQty > 0 ? `<div><strong>Remaining:</strong> ${esc(remainingQty)}</div>` : ``}
${item.upc ? `<div><strong>UPC:</strong> ${esc(item.upc)}</div>` : ``}
${item.plu ? `<div><strong>PLU:</strong> ${esc(item.plu)}</div>` : ``}
${substitutionsAllowed ? `<div><strong>Substitutions:</strong> Allowed</div>` : `<div><strong>Substitutions:</strong> Not allowed</div>`}
${unavailable ? `<div style="color:#b00;"><strong>Unavailable / Issue Recorded</strong></div>` : ``}
${item.substitution_name ? `<div><strong>Sub:</strong> ${esc(item.substitution_name)}</div>` : ``}
${item.substitution_upc ? `<div><strong>Sub UPC:</strong> ${esc(item.substitution_upc)}</div>` : ``}
${item.substitution_notes ? `<div><strong>Notes:</strong> ${esc(item.substitution_notes)}</div>` : ``}
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
${item.upc && !done ? `<button data-scan style="padding:10px 12px;border-radius:10px;border:1px solid #111;background:#fff;cursor:pointer;">Scan to Confirm</button>` : ``}
${item.plu && !done ? `<button data-plu style="padding:10px 12px;border-radius:10px;border:1px solid #bbb;background:#fff;cursor:pointer;">Enter PLU</button>` : ``}
${substitutionsAllowed && !done ? `<button data-sub-scan style="padding:10px 12px;border-radius:10px;border:1px solid #444;background:#fff;cursor:pointer;">Scan Substitute</button>` : ``}
<button data-sub style="padding:10px 12px;border-radius:10px;border:1px solid #bbb;background:#fff;cursor:pointer;">Unavailable / Substitute</button>
</div>
</div>
`;
const scanBtn = row.querySelector("[data-scan]");
if (scanBtn) scanBtn.onclick = () => startScanner(item, "barcode");
const subScanBtn = row.querySelector("[data-sub-scan]");
if (subScanBtn) subScanBtn.onclick = () => startSubstituteScanner(item);
const pluBtn = row.querySelector("[data-plu]");
if (pluBtn) {
pluBtn.onclick = async () => {
const val = prompt(`Enter PLU for: ${item.name}`, item.plu || "");
if (!val) return;
const qtyToSubmit = await confirmPickedQuantity({
...item,
remaining_qty: remainingQtyForItem(item)
});
if (qtyToSubmit === null) return;
await submitScan(item.order_item_id, val.trim(), "plu", val.trim(), item.upc || "", qtyToSubmit);
};
}
const subBtn = row.querySelector("[data-sub]");
subBtn.onclick = async () => {
const meta = itemMeta(item);
const substitutionsAllowed = meta.allowSubstitutions !== false;
if (!substitutionsAllowed) {
const noteOnly = prompt(
"Customer did not allow substitutions. Add unavailable note:",
item.substitution_notes || "Customer did not allow substitutions."
);
if (noteOnly === null) return;
try {
setStatus("Saving unavailable note…");
await apiPost(
`/v1/orders/${encodeURIComponent(orderId)}/items/${encodeURIComponent(item.order_item_id)}/substitution?${tokenQS()}`,
{
unavailable: true,
substitution_name: null,
substitution_upc: null,
substitution_notes: noteOnly || "Customer did not allow substitutions."
}
);
await refreshPicklistDirect("Unavailable note saved.");
} catch (e) {
setStatus("Error: " + e.message);
}
return;
}
const useSub = confirm(
"Substitutions are allowed.\n\n" +
"Did you pick an alternate/substitute item?\n\n" +
"OK = Enter substitute details\n" +
"Cancel = Mark unavailable / add note only"
);
if (!useSub) {
const notes = prompt("Unavailable or shortage notes:", item.substitution_notes || "");
if (notes === null) return;
try {
setStatus("Saving note…");
await apiPost(
`/v1/orders/${encodeURIComponent(orderId)}/items/${encodeURIComponent(item.order_item_id)}/substitution?${tokenQS()}`,
{
unavailable: true,
substitution_name: null,
substitution_upc: null,
substitution_notes: notes || "Item unavailable. Substitute not selected."
}
);
await refreshPicklistDirect("Unavailable note saved.");
} catch (e) {
setStatus("Error: " + e.message);
}
return;
}
const subUpc = prompt("Substitute UPC, if available:", item.substitution_upc || "");
const subName = prompt("Substitute name:", item.substitution_name || "");
const subQtyRaw = prompt("Substitute quantity:", String(remainingQtyForItem(item) || 1));
if (subName === null && subUpc === null) return;
const subQty = Math.max(1, parseInt(subQtyRaw || "1", 10) || 1);
let substituteProduct = null;
if (subUpc) {
setStatus("Looking up substitute item…");
substituteProduct = await lookupKrogerProductByUpc(subUpc);
}
if (!substituteProduct) {
substituteProduct = {
name: subName || "Substitute item",
upc: subUpc || "",
size: "",
brand: ""
};
}
await saveValidatedSubstitution(item, subUpc || "", substituteProduct, subQty);
};
listEl.appendChild(row);
});
}
async function submitScan(orderItemId, codeToSend, mode, rawScanned, expectedUpc, qtyToSubmit = 1) {
if (busy) return;
busy = true;
refreshBtn.disabled = true;
undoBtn.disabled = true;
try {
setStatus("Submitting scan…");
const safeQty = Math.max(1, parseInt(qtyToSubmit || 1, 10));
const res = await apiPost(`/v1/orders/${encodeURIComponent(orderId)}/scan?${tokenQS()}`, {
order_item_id: orderItemId,
scanned_code: codeToSend,
qty: safeQty,
mode
});
if (!res.accepted) {
alert(
"Rejected: " + (res.reason || "not accepted") +
"\nExpected: " + (expectedUpc || "(none)") +
"\nScanned: " + (rawScanned || "(none)") +
"\nSent: " + (codeToSend || "(none)")
);
await refreshPicklistDirect("Rejected: " + (res.reason || "not accepted"));
return;
}
if (picklist && Array.isArray(picklist.items)) {
const localItem = picklist.items.find(x => String(x.order_item_id) === String(orderItemId));
if (localItem) {
if (typeof res.qty_picked === "number") {
localItem.qty_picked = res.qty_picked;
} else {
const currentPicked = parseInt(localItem.qty_picked || 0, 10) || 0;
const required = parseInt(localItem.qty_required || 1, 10) || 1;
localItem.qty_picked = Math.min(required, currentPicked + safeQty);
}
renderPicklist();
}
}
scheduleAcceptedRefresh();
} catch (e) {
setStatus("Error: " + e.message);
} finally {
busy = false;
refreshBtn.disabled = false;
undoBtn.disabled = false;
}
}
async function getRearCameraDeviceId() {
try {
const devices = await ZXing.BrowserCodeReader.listVideoInputDevices();
if (!devices || !devices.length) return null;
const rear = devices.find(d =>
/back|rear|environment/i.test(d.label || "")
);
return (rear || devices[devices.length - 1]).deviceId || null;
} catch (e) {
return null;
}
}
async function startSubstituteScanner(item) {
if (scanLock) return;
const remaining = remainingQtyForItem(item);
const substituteQtyRaw = prompt(
`${item.name || "This item"} needs ${remaining || 1} remaining unit(s).\n\n` +
`How many substitute units did you pick?`,
String(remaining || 1)
);
if (substituteQtyRaw === null) {
setStatus("Substitute scan cancelled.");
return;
}
const substituteQty = Math.max(1, parseInt(substituteQtyRaw || "1", 10) || 1);
scanItemEl.textContent = `— Substitute for ${item.name || "item"}`;
scannerWrap.style.display = "block";
videoEl.setAttribute("autoplay", "true");
videoEl.setAttribute("muted", "true");
videoEl.setAttribute("playsinline", "true");
try {
const rearCameraId = await getRearCameraDeviceId();
await reader.decodeFromVideoDevice(rearCameraId, videoEl, async (result) => {
if (!result) return;
if (scanLock) return;
scanLock = true;
const scannedCode = result.getText();
stopScanner();
setTimeout(() => {
scanLock = false;
}, 1200);
setStatus("Looking up substitute item…");
const substituteProduct = await lookupKrogerProductByUpc(scannedCode);
await saveValidatedSubstitution(
item,
scannedCode,
substituteProduct || {
name: "",
upc: scannedCode,
size: "",
brand: ""
},
substituteQty
);
});
} catch (e) {
alert("Camera error. Confirm Safari camera permission and that this page is HTTPS.");
stopScanner();
scanLock = false;
}
}
async function startScanner(item, mode) {
if (scanLock) return;
scanTarget = {
order_item_id: item.order_item_id,
mode,
name: item.name,
expected_upc: item.upc || "",
qty_required: item.qty_required || 1,
qty_picked: item.qty_picked || 0,
remaining_qty: remainingQtyForItem(item)
};
scanItemEl.textContent = `— ${item.name}`;
scannerWrap.style.display = "block";
videoEl.setAttribute("autoplay", "true");
videoEl.setAttribute("muted", "true");
videoEl.setAttribute("playsinline", "true");
try {
const rearCameraId = await getRearCameraDeviceId();
await reader.decodeFromVideoDevice(rearCameraId, videoEl, async (result) => {
if (!result) return;
if (scanLock) return;
scanLock = true;
const targetId = scanTarget?.order_item_id;
const targetMode = scanTarget?.mode;
const expectedUpc = scanTarget?.expected_upc || "";
const rawScanned = result.getText();
stopScanner();
setTimeout(() => {
scanLock = false;
}, 1200);
if (!targetId || !targetMode) {
setStatus("Error: scan target missing. Try again.");
return;
}
let codeToSend = rawScanned;
if (expectedUpc && codesMatchLocal(rawScanned, expectedUpc)) {
codeToSend = expectedUpc;
}
const qtyToSubmit = await confirmPickedQuantity(scanTarget);
if (qtyToSubmit === null) {
return;
}
await submitScan(targetId, codeToSend, targetMode, rawScanned, expectedUpc, qtyToSubmit);
});
} catch (e) {
alert("Camera error. Confirm Safari camera permission and that this page is HTTPS.");
stopScanner();
scanLock = false;
}
}
function stopScanner() {
try { reader.reset(); } catch {}
scannerWrap.style.display = "none";
scanTarget = null;
}
async function undoLast() {
if (busy) return;
if (requireTokenUI()) return;
const ok = confirm("Undo the last accepted scan?");
if (!ok) return;
busy = true;
refreshBtn.disabled = true;
undoBtn.disabled = true;
try {
setStatus("Undoing last scan…");
const res = await apiPost(`/v1/orders/${encodeURIComponent(orderId)}/undo-last?${tokenQS()}`, {});
await refreshPicklistDirect(res.message || "Undone.");
} catch (e) {
setStatus("Error: " + e.message);
} finally {
busy = false;
refreshBtn.disabled = false;
undoBtn.disabled = false;
}
}
closeScanBtn.onclick = stopScanner;
refreshBtn.onclick = loadPicklist;
undoBtn.onclick = undoLast;
loadBtn.onclick = () => {
const oid = (orderIdInput.value || "").trim();
const tok = (tokenInput.value || "").trim();
if (!oid || !tok) return;
orderId = oid;
token = tok;
setUrlParams(orderId, token);
loadPicklist();
};
loadPicklist();
})();
</script>