Procházet zdrojové kódy

CW-STORY-04.5: build scan page; complete CW-EPIC-04

Camera-based barcode scan page using the native BarcodeDetector API
(no bundler in this project, so no vendored scanning library) with a
manual-entry fallback for unsupported browsers. Adds the
/products/barcode/{barcode} and /api/products/barcode/{barcode}
routes ProductController was missing, plus an "Add to my list" action
on product details. Verified live against the real Open Food Facts
API end to end. "Record price" deferred to CW-EPIC-05, which doesn't
exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington před 1 týdnem
rodič
revize
6fd802fc27
10 změnil soubory, kde provedl 256 přidání a 8 odebrání
  1. +5
    -1
      .claude/settings.json
  2. +9
    -1
      README.md
  3. +13
    -5
      docs/scrum-backlog.md
  4. +29
    -0
      src/CartWise.Web/Controllers/ProductController.cs
  5. +12
    -0
      src/CartWise.Web/Controllers/ScanController.cs
  6. +6
    -1
      src/CartWise.Web/Views/Product/Details.cshtml
  7. +31
    -0
      src/CartWise.Web/Views/Scan/Index.cshtml
  8. +3
    -0
      src/CartWise.Web/Views/Shared/_Layout.cshtml
  9. +86
    -0
      src/CartWise.Web/wwwroot/js/pages/scan.js
  10. +62
    -0
      tests/CartWise.Web.Tests/ScanAndBarcodeTests.cs

+ 5
- 1
.claude/settings.json Zobrazit soubor

@@ -32,7 +32,11 @@
"Bash(curl -s http://127.0.0.1:5187/css/site.css)",
"PowerShell(\"done\")",
"Bash(grep -n \"^### \\\\`CW-STORY\\\\|^## \\\\`CW-EPIC\" docs/scrum-backlog.md)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify3.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify3.db\" 2>&1 | tail -15 && rm -f verify3.db*)"
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify3.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify3.db\" 2>&1 | tail -15 && rm -f verify3.db*)",
"Bash(curl -s -m 8 \"https://world.openfoodfacts.org/api/v2/product/3017620422003.json\")",
"Bash(curl -s -b /tmp/cw5.txt -m 15 http://127.0.0.1:5187/api/products/barcode/3017620422003)",
"Bash(curl -s -b /tmp/cw5.txt http://127.0.0.1:5187/products/0ce5df7a-6c8d-489d-ba9f-8d41699f0705 -o /tmp/product.html -w \"status=%{http_code}\\\\n\")",
"Bash(sed -n '/<h1>/,/Add to my list/p' /tmp/product.html)"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"


+ 9
- 1
README.md Zobrazit soubor

@@ -168,7 +168,15 @@ Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level st
- [x] `CW-STORY-03.4` Toggle purchased/skip/delete with optimistic concurrency handling and jQuery progressive enhancement
- [x] `CW-STORY-03.5` Mobile-first/touch-friendly polish pass (not visually verified in a browser — no browser-automation tool available in this environment; recommend a quick manual check)

Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence (`CW-EPIC-04` is post-MVP).
`CW-EPIC-04` Product Catalog and Barcode Resolution is complete — built ahead of the MVP-tagged epics per `DEC-010` (explicit founder direction to work epics in strict numeric order rather than MVP-first):

- [x] `CW-STORY-04.1` Brand/Product/ProductIdentifier/HouseholdProductPreference entities, EF configuration, migration
- [x] `CW-STORY-04.2` Local product search and details pages
- [x] `CW-STORY-04.3` Local-first barcode lookup flow with provider fallback (`IProductDataProvider`)
- [x] `CW-STORY-04.4` Real `OpenFoodFactsProductDataProvider` (typed `HttpClient`, no API key required)
- [x] `CW-STORY-04.5` Camera-based scan page (native `BarcodeDetector` API, manual-entry fallback) with add-to-list — verified live against the real Open Food Facts API

Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence.

Next up: `CW-EPIC-03` Smart Shopping List.



+ 13
- 5
docs/scrum-backlog.md Zobrazit soubor

@@ -520,11 +520,19 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t
- User can add scanned product to the list or record a price

**Tasks**
- [ ] Create `ScanController`
- [ ] Build `Views/Scan/Index.cshtml`
- [ ] Add camera permission flow
- [ ] Add barcode JS integration
- [ ] Add add-to-list and record-price actions
- [x] Create `ScanController`
- [x] Build `Views/Scan/Index.cshtml`
- [x] Add camera permission flow
- [x] Add barcode JS integration
- [x] Add add-to-list and record-price actions

**Status:** Done, with one deliberate partial: "record-price" isn't wired up because `CW-EPIC-05` (Purchases/Price Intelligence) hasn't been built yet — a direct consequence of `DEC-010`'s strict-numeric-order choice reaching `04.5` before `05` exists. "Add to list" is fully wired instead, which satisfies the acceptance criterion's "add ... to the list **or** record a purchase/price." Will revisit once `CW-EPIC-05`'s `PriceController` exists.

Also added `GET /products/barcode/{barcode}` and `GET /api/products/barcode/{barcode}` to `ProductController` (per AGENTS.md §11 — these were specified but not yet built in `CW-STORY-04.2`/`04.3`, since neither of those stories' task lists mentioned a controller route; the scan page is what actually needed them). The JSON route is the "actual client-side need" AGENTS.md §11 requires before introducing an `/api` route — the scan page's JS calls it via `fetch`/`$.getJSON` to resolve a detected barcode without a full page reload.

No barcode-scanning JS library is vendored — this project has no bundler/npm step (`wwwroot` is plain static files, matching the existing jQuery/Bootstrap vendoring pattern), and pulling in a third-party scanning library's browser build wasn't practical to do well without one. Instead, `wwwroot/js/pages/scan.js` uses the native browser `BarcodeDetector` API (Chrome/Edge/Android WebView) with graceful degradation: browsers lacking it get a clear "not supported" message and a manual barcode-entry text field instead, which also doubles as the non-JS fallback path per AGENTS.md §14.

Verified live end-to-end with a **real** barcode against the **real** Open Food Facts API (not mocked): `GET /api/products/barcode/3017620422003` (Nutella) → provider fallback fires → product persists locally with the correct name/brand/identifier → `/products/{id}` renders it → "Add to my list" persists it onto the household's shopping list. Also confirmed a genuinely-unknown barcode correctly 404s (HTML route) / returns `{"found":false}` (JSON route) after the provider fallback misses too.

---



+ 29
- 0
src/CartWise.Web/Controllers/ProductController.cs Zobrazit soubor

@@ -60,4 +60,33 @@ public class ProductController : Controller

return View(viewModel);
}

[HttpGet("products/barcode/{barcode}")]
public async Task<IActionResult> Barcode(string barcode)
{
var result = await _productService.ResolveBarcodeAsync(barcode);
if (!result.IsSuccess)
{
return NotFound(result.Error);
}

return RedirectToAction(nameof(Details), new { id = result.Value!.ProductId });
}

[HttpGet("api/products/barcode/{barcode}")]
public async Task<IActionResult> BarcodeJson(string barcode)
{
var result = await _productService.ResolveBarcodeAsync(barcode);
if (!result.IsSuccess)
{
return Ok(new { found = false, error = result.Error });
}

return Ok(new
{
found = true,
productId = result.Value!.ProductId,
redirectUrl = Url.Action(nameof(Details), new { id = result.Value.ProductId })
});
}
}

+ 12
- 0
src/CartWise.Web/Controllers/ScanController.cs Zobrazit soubor

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace CartWise.Web.Controllers;

[Authorize]
[Route("scan")]
public class ScanController : Controller
{
[HttpGet("")]
public IActionResult Index() => View();
}

+ 6
- 1
src/CartWise.Web/Views/Product/Details.cshtml Zobrazit soubor

@@ -29,4 +29,9 @@
</ul>
}

<p class="text-muted mt-4"><em>Price history and household insights will appear here once purchase tracking is available.</em></p>
<form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="mt-4">
<input type="hidden" name="NewItem.DisplayName" value="@Model.Name" />
<button type="submit" class="btn btn-primary">Add to my list</button>
</form>

<p class="text-muted mt-4"><em>Price history and recording a purchase will appear here once purchase tracking is available.</em></p>

+ 31
- 0
src/CartWise.Web/Views/Scan/Index.cshtml Zobrazit soubor

@@ -0,0 +1,31 @@
@{
ViewData["Title"] = "Scan a barcode";
}

<h1>Scan a barcode</h1>

<div id="scan-unsupported" class="alert alert-warning d-none" role="alert">
Live camera scanning isn't supported in this browser. Enter the barcode manually below.
</div>

<div id="scan-camera-container" class="mb-3 d-none">
<video id="scan-video" class="w-100" autoplay playsinline muted></video>
</div>

<button id="scan-start" type="button" class="btn btn-primary mb-3">Start camera</button>

<form id="scan-manual-form" class="row g-2">
<div class="col-9">
<label for="manual-barcode" class="visually-hidden">Barcode</label>
<input type="text" id="manual-barcode" class="form-control" placeholder="Or enter a barcode" inputmode="numeric" />
</div>
<div class="col-3">
<button type="submit" class="btn btn-outline-secondary w-100">Look up</button>
</div>
</form>

<div id="scan-result" class="mt-3" aria-live="polite"></div>

@section Scripts {
<script src="~/js/pages/scan.js" asp-append-version="true"></script>
}

+ 3
- 0
src/CartWise.Web/Views/Shared/_Layout.cshtml Zobrazit soubor

@@ -31,6 +31,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-controller="Household" asp-action="Index">Household</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-controller="Scan" asp-action="Index">Scan</a>
</li>
}
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>


+ 86
- 0
src/CartWise.Web/wwwroot/js/pages/scan.js Zobrazit soubor

@@ -0,0 +1,86 @@
$(function () {
var $unsupported = $('#scan-unsupported');
var $cameraContainer = $('#scan-camera-container');
var $video = $('#scan-video');
var $startButton = $('#scan-start');
var $result = $('#scan-result');
var $manualForm = $('#scan-manual-form');

function resolveBarcode(barcode) {
$result.html('<p class="text-muted">Looking up ' + barcode + '…</p>');

$.getJSON('/api/products/barcode/' + encodeURIComponent(barcode))
.done(function (response) {
if (response.found) {
window.location.href = response.redirectUrl;
} else {
$result.html('<p class="text-danger">' + (response.error || 'Product not found.') + '</p>');
}
})
.fail(function () {
$result.html('<p class="text-danger">Something went wrong looking up that barcode.</p>');
});
}

$manualForm.on('submit', function (event) {
event.preventDefault();
var barcode = $('#manual-barcode').val().trim();
if (barcode) {
resolveBarcode(barcode);
}
});

if (!('BarcodeDetector' in window) || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
$unsupported.removeClass('d-none');
$startButton.prop('disabled', true);
return;
}

var detector = new BarcodeDetector({ formats: ['upc_a', 'upc_e', 'ean_13', 'ean_8'] });
var activeStream = null;
var scanning = false;

function stopScanning() {
scanning = false;
if (activeStream) {
activeStream.getTracks().forEach(function (track) { track.stop(); });
activeStream = null;
}
}

function scanLoop() {
if (!scanning) {
return;
}

detector.detect($video[0])
.then(function (barcodes) {
if (barcodes.length > 0) {
var barcode = barcodes[0].rawValue;
stopScanning();
resolveBarcode(barcode);
} else {
requestAnimationFrame(scanLoop);
}
})
.catch(function () {
requestAnimationFrame(scanLoop);
});
}

$startButton.on('click', function () {
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
.then(function (mediaStream) {
activeStream = mediaStream;
$video[0].srcObject = mediaStream;
$cameraContainer.removeClass('d-none');
scanning = true;
scanLoop();
})
.catch(function () {
$result.html('<p class="text-danger">Camera permission was denied.</p>');
});
});

$(window).on('beforeunload', stopScanning);
});

+ 62
- 0
tests/CartWise.Web.Tests/ScanAndBarcodeTests.cs Zobrazit soubor

@@ -0,0 +1,62 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;

namespace CartWise.Web.Tests;

public class ScanAndBarcodeTests : IClassFixture<CartWiseWebApplicationFactory>
{
private readonly CartWiseWebApplicationFactory _factory;

public ScanAndBarcodeTests(CartWiseWebApplicationFactory factory)
{
_factory = factory;
}

[Fact]
public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingScanPage()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

var response = await client.GetAsync("/scan");

Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Contains("/Account/Login", response.Headers.Location?.ToString());
}

[Fact]
public async Task AuthenticatedUser_CanReachScanPage()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "scanuser1@example.com", "Scan User");

var response = await client.GetAsync("/scan");

response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
HtmlAssert.Contains("Scan a barcode", body);
}

[Fact]
public async Task BarcodeHtmlRoute_ReturnsNotFoundForUnknownBarcode()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "scanuser2@example.com", "Scan User 2");

var response = await client.GetAsync("/products/barcode/000000000000");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task BarcodeJsonRoute_ReturnsFoundFalseForUnknownBarcode()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "scanuser3@example.com", "Scan User 3");

var response = await client.GetAsync("/api/products/barcode/000000000000");

response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("\"found\":false", body);
}
}

Načítá se…
Zrušit
Uložit

Powered by TurnKey Linux.