Add product image to powder inventory via AI lookup

When AI Lookup fetches a manufacturer product page, it now extracts the
og:image (Open Graph) meta tag before stripping HTML tags. The image URL
is returned in InventoryAiLookupResult.ImageUrl and automatically shown
as a preview on the Create/Edit form alongside the other filled fields.

The preview includes a Remove button to clear the image, and the Wrong
Match? button clears it along with the other AI-filled fields.

On the inventory Details page a product image card is rendered above the
Stock & Pricing card whenever ImageUrl is set. The field is nullable so
existing records and powders without an image are unaffected.

New field: InventoryItem.ImageUrl (nvarchar, nullable).
Migration: AddInventoryItemImageUrl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 18:15:55 -04:00
parent 9221fcc783
commit 90a06c6acd
11 changed files with 9534 additions and 10 deletions
@@ -123,6 +123,17 @@
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
<div class="d-flex align-items-start gap-3">
<img id="field-imagepreview-img" src="@Model.ImageUrl" alt="Product image"
style="max-height:120px;max-width:160px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;background:#f8f9fa;padding:4px;" />
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-clear-image" title="Remove image">
<i class="bi bi-x me-1"></i>Remove
</button>
</div>
</div>
<div class="col-md-6" id="wrap-coverage">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
@@ -308,6 +308,16 @@
<!-- Right Column -->
<div class="col-lg-4">
@if (!string.IsNullOrWhiteSpace(Model.ImageUrl))
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3 text-center">
<img src="@Model.ImageUrl" alt="@Model.Name"
style="max-width:100%;max-height:200px;object-fit:contain;" />
</div>
</div>
}
<!-- Stock, Pricing & Status -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-2 d-flex align-items-center gap-2">
@@ -125,6 +125,17 @@
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
<div class="d-flex align-items-start gap-3">
<img id="field-imagepreview-img" src="@Model.ImageUrl" alt="Product image"
style="max-height:120px;max-width:160px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;background:#f8f9fa;padding:4px;" />
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-clear-image" title="Remove image">
<i class="bi bi-x me-1"></i>Remove
</button>
</div>
</div>
<div class="col-md-6" id="wrap-coverage">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
@@ -153,8 +153,33 @@
let aiFilledColorFamilies = false;
let aiFilledVendor = false;
let aiFilledClearCoat = false;
let aiFilledImage = false;
let forceRefill = false; // set true for bad-match retry
function setImagePreview(url) {
const wrap = document.getElementById('wrap-imagepreview');
const img = document.getElementById('field-imagepreview-img');
const inp = document.getElementById('field-imageurl');
if (!wrap || !img || !inp) return;
if (url) {
inp.value = url;
img.src = url;
wrap.style.display = '';
} else {
inp.value = '';
img.src = '';
wrap.style.display = 'none';
}
}
const clearImageBtn = document.getElementById('btn-clear-image');
if (clearImageBtn) {
clearImageBtn.addEventListener('click', () => {
setImagePreview('');
aiFilledImage = false;
});
}
function autoComposeName() {
if (!isCoatingCategory(categorySelect?.value)) return;
const color = colorNameEl?.value?.trim() ?? '';
@@ -257,6 +282,7 @@
if (cc) cc.checked = false;
aiFilledClearCoat = false;
}
if (aiFilledImage) { setImagePreview(''); aiFilledImage = false; }
aiFilledFields = [];
lastAutoName = '';
forceRefill = false;
@@ -421,6 +447,13 @@
}
}
// Product image — show preview if returned and not already set
if (data.imageUrl && (forceRefill || !document.getElementById('field-imageurl')?.value?.trim())) {
setImagePreview(data.imageUrl);
filled.push('Product Image');
aiFilledImage = true;
}
// Build a persistent "needs more info" tip if key identity fields are still unknown
const missingHints = [];
if (!data.manufacturer && !document.getElementById('field-manufacturer')?.value?.trim())