diff --git a/README.md b/README.md index e6bc611..f67109b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The file is optional — if it is missing or invalid, seeding is silently skippe - Items grouped by category and assigned from the add/edit forms - Meals with ingredients, markdown descriptions, and one-click "add meal to list" - Rewards cards with store name and number, rendered as scannable Code 128 / Code 39 barcodes +- Single-card scan view that shows one barcode at a time and keeps the screen awake for scanning - Server-authoritative last-write-wins updates - Per-list WebSocket updates with server-rendered htmx fragments - In-memory presence for members currently viewing a list diff --git a/e2e/tests/rewards.spec.ts b/e2e/tests/rewards.spec.ts index e482482..128434b 100644 --- a/e2e/tests/rewards.spec.ts +++ b/e2e/tests/rewards.spec.ts @@ -115,4 +115,55 @@ test("cancelling the remove confirmation keeps the rewards card", async ({ // The card must remain after cancelling. await expect(page.locator(".rewards-card")).toHaveCount(1); await expect(page.locator(".rewards-card").filter({ hasText: "Kroger" })).toBeVisible(); +}); + +test("a user can open a single-card scan view with only one barcode", async ({ + page, +}) => { + await registerAndLogin(page, "alice@example.com"); + await gotoRewards(page); + await addCard(page, "Kroger", "606171584511340224537"); + await addCard(page, "Safeway", "012345678901"); + + // Click the Kroger card's barcode to open its focused scan view. + await page + .locator(".rewards-card") + .filter({ hasText: "Kroger" }) + .locator(".rewards-card-link") + .click(); + + await expect(page).toHaveURL(/\/rewards\/\d+/); + // Only one barcode is rendered on the scan page. + await expect(page.locator(".scan-barcode svg")).toHaveCount(1); + await expect(page.locator(".scan-store")).toHaveText("Kroger"); + await expect(page.locator(".scan-card .rewards-number")).toHaveText( + "606171584511340224537", + ); + // The wake-lock script is loaded on the scan page. + await expect(page.locator('script[src*="rewards.js"]')).toHaveCount(1); + + // Back link returns to the list. + await page.click('.scan-back'); + await expect(page).toHaveURL(/\/rewards$/); +}); + +test("a scan view for another user's card is not accessible", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await gotoRewards(page); + await addCard(page, "Kroger", "606171584511340224537"); + + // Grab the card id from the URL of the scan view. + await page + .locator(".rewards-card-link") + .click(); + const url = page.url(); + const cardId = url.split("/").pop(); + + // Sign out and sign in as a different user. + await page.click('button:has-text("Sign out")'); + await registerAndLogin(page, "bob@example.com"); + + // Bob cannot view Alice's card. + await page.goto(`/rewards/${cardId}`); + await expect(page.locator(".scan-barcode svg")).toHaveCount(0); }); \ No newline at end of file diff --git a/src/assets.rs b/src/assets.rs index 4a9c176..f9434c9 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -65,6 +65,7 @@ pub static STORE: LazyLock = LazyLock::new(|| { "passkey-login.js" => "../static/passkey-login.js", "passkey-register.js" => "../static/passkey-register.js", "password-toggle.js" => "../static/password-toggle.js", + "rewards.js" => "../static/rewards.js", "htmx.min.js" => "../static/htmx.min.js", "idiomorph-ext.min.js" => "../static/idiomorph-ext.min.js", "htmx-ws.min.js" => "../static/htmx-ws.min.js", diff --git a/src/http.rs b/src/http.rs index 3f296f7..c534458 100644 --- a/src/http.rs +++ b/src/http.rs @@ -134,6 +134,7 @@ pub fn build_router(state: AppState) -> Router { post(remove_meal_from_list), ) .route("/rewards", get(rewards_page).post(create_rewards_card)) + .route("/rewards/{card_id}", get(rewards_scan_page)) .route("/rewards/{card_id}/delete", post(delete_rewards_card)) .route("/lists/{list_id}/stream", get(list_stream)) .route("/invite/{token}", get(invitation_page)) @@ -845,6 +846,22 @@ async fn rewards_page( ))) } +async fn rewards_scan_page( + State(state): State, + user: CurrentUser, + Path(card_id): Path, +) -> Result { + let card = state + .rewards_cards + .get_card(user.session.user.id, card_id) + .await? + .ok_or(AppError::NotFound)?; + Ok(html_response(views::rewards_scan_page( + &user.session.user, + &card, + ))) +} + async fn create_rewards_card( State(state): State, user: CurrentUser, diff --git a/src/ports.rs b/src/ports.rs index c717032..978812b 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -323,6 +323,12 @@ pub trait RewardsCardRepository: Send + Sync { txn: &mut SqliteConnection, user_id: i64, ) -> DomainResult>; + async fn get_card( + &self, + txn: &mut SqliteConnection, + user_id: i64, + card_id: i64, + ) -> DomainResult>; async fn create_card( &self, txn: &mut SqliteConnection, diff --git a/src/services.rs b/src/services.rs index eaf99fc..d867034 100644 --- a/src/services.rs +++ b/src/services.rs @@ -648,6 +648,13 @@ impl RewardsCardService { .await } + pub async fn get_card(&self, user_id: i64, card_id: i64) -> DomainResult> { + let cards = Arc::clone(&self.cards); + self.db + .run(move |txn| Box::pin(async move { cards.get_card(txn, user_id, card_id).await })) + .await + } + pub async fn create_card( &self, user_id: i64, diff --git a/src/sqlite.rs b/src/sqlite.rs index b426f1c..6e3a844 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -1315,17 +1315,26 @@ impl RewardsCardRepository for SqliteRewardsCardRepository { .fetch_all(&mut *txn) .await .map_err(db_error)?; - Ok(rows - .into_iter() - .map(|row| RewardsCard { - id: row.get(0), - user_id: row.get(1), - store_name: row.get(2), - number: row.get(3), - symbology: row.get(4), - created_at: row.get(5), - }) - .collect()) + Ok(rows.into_iter().map(map_rewards_card_row).collect()) + } + + async fn get_card( + &self, + txn: &mut SqliteConnection, + user_id: i64, + card_id: i64, + ) -> DomainResult> { + let row = sqlx::query( + "SELECT id, user_id, store_name, number, symbology, created_at + FROM rewards_cards + WHERE id = ?1 AND user_id = ?2", + ) + .bind(card_id) + .bind(user_id) + .fetch_optional(&mut *txn) + .await + .map_err(db_error)?; + Ok(row.map(map_rewards_card_row)) } async fn create_card( @@ -1383,6 +1392,18 @@ impl RewardsCardRepository for SqliteRewardsCardRepository { } } +/// Maps a `rewards_cards` row (in the shared column order) to a `RewardsCard`. +fn map_rewards_card_row(row: sqlx::sqlite::SqliteRow) -> RewardsCard { + RewardsCard { + id: row.get(0), + user_id: row.get(1), + store_name: row.get(2), + number: row.get(3), + symbology: row.get(4), + created_at: row.get(5), + } +} + async fn ensure_category(txn: &mut SqliteConnection, category_id: Option) -> DomainResult<()> { let Some(category_id) = category_id else { return Ok(()); @@ -3045,4 +3066,61 @@ mod tests { .await; assert!(matches!(result, Err(DomainError::NotFound))); } + + #[tokio::test] + async fn get_rewards_card_returns_card_or_none() { + let db = setup().await; + let alice = create_user(&db, "alice@example.com").await; + let bob = create_user(&db, "bob@example.com").await; + let cards = SqliteRewardsCardRepository; + + let created = db + .run(move |txn| { + let cards = cards.clone(); + Box::pin(async move { + cards + .create_card( + txn, + alice.id, + "Kroger".into(), + "601123456789".into(), + "code128".into(), + ) + .await + }) + }) + .await + .unwrap(); + + // The owner can fetch their card. + let found = db + .run(move |txn| { + let cards = cards.clone(); + Box::pin(async move { cards.get_card(txn, alice.id, created.id).await }) + }) + .await + .unwrap(); + assert_eq!(found.as_ref().map(|c| c.id), Some(created.id)); + assert_eq!(found.unwrap().store_name, "Kroger"); + + // Another user cannot fetch it. + let not_found = db + .run(move |txn| { + let cards = cards.clone(); + Box::pin(async move { cards.get_card(txn, bob.id, created.id).await }) + }) + .await + .unwrap(); + assert!(not_found.is_none()); + + // A missing id returns None. + let missing = db + .run(move |txn| { + let cards = cards.clone(); + Box::pin(async move { cards.get_card(txn, alice.id, 9999).await }) + }) + .await + .unwrap(); + assert!(missing.is_none()); + } } diff --git a/src/views.rs b/src/views.rs index 5520b91..586c7bb 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1434,6 +1434,7 @@ pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Mar div class="rewards-grid" { @for card in cards { div class="rewards-card" { + a class="rewards-card-link" href=(format!("/rewards/{}", card.id)) aria-label=(format!("Show {} barcode", card.store_name)) {} div class="rewards-card-heading" { strong { (card.store_name) } form method="post" action=(format!("/rewards/{}/delete", card.id)) hx-confirm="Remove this rewards card? This cannot be undone." { @@ -1471,6 +1472,29 @@ pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Mar ) } +/// A focused, single-barcode view for scanning at the register. Only one +/// barcode is shown at a time so a scanner can't pick up multiple codes. +pub fn rewards_scan_page(user: &User, card: &RewardsCard) -> Markup { + page( + &card.store_name, + Some(user), + html! { + div class="scan-page" { + a class="button button-quiet scan-back" href="/rewards" { "← All cards" } + div class="scan-card" { + p class="eyebrow" { "REWARDS CARD" } + h1 class="scan-store" { (card.store_name) } + div class="scan-barcode" { + (barcode_svg(&card.symbology, &card.number)) + } + p class="rewards-number" { (card.number) } + } + } + script src=(crate::assets::url("rewards.js")) {} + }, + ) +} + /// Renders a rewards-card number as an inline SVG barcode using the given /// symbology. Falls back to a plain text label if the number can't be encoded /// (for example, a Code 128 number that isn't valid for the chosen symbology). diff --git a/static/rewards.js b/static/rewards.js new file mode 100644 index 0000000..22ff6ba --- /dev/null +++ b/static/rewards.js @@ -0,0 +1,30 @@ +// Keep the screen awake while a rewards barcode is on screen so the cashier +// can scan it without the display dimming or locking. This mirrors how wallet +// apps behave when showing a barcode. The Screen Wake Lock API is best-effort: +// it may be rejected (e.g. low battery) or auto-released when the tab is hidden, +// so we re-acquire whenever the page becomes visible again. +(function () { + var wakeLock = null; + + function requestWakeLock() { + if (!("wakeLock" in navigator)) return; + navigator.wakeLock + .request("screen") + .then(function (sentinel) { + wakeLock = sentinel; + }) + .catch(function () { + // Best-effort only; ignore failures (unsupported, low battery, etc.). + }); + } + + // Re-acquire if the lock was released (e.g. the tab was hidden) and the user + // returns to the page. + document.addEventListener("visibilitychange", function () { + if (document.visibilityState === "visible" && wakeLock === null) { + requestWakeLock(); + } + }); + + requestWakeLock(); +})(); diff --git a/static/style.css b/static/style.css index 2cd44ff..d10ed9c 100644 --- a/static/style.css +++ b/static/style.css @@ -412,10 +412,20 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, /* Rewards cards */ .rewards-grid { display: grid; gap: 16px; } .rewards-card { + position: relative; padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: #fff; + transition: border-color .16s ease, transform .16s ease; +} +.rewards-card:hover { border-color: var(--sage); transform: translateY(-1px); } +/* Stretched link: makes the whole card clickable to open the scan view. */ +.rewards-card-link { + position: absolute; + inset: 0; + border-radius: inherit; + z-index: 1; } .rewards-card-heading { display: flex; @@ -445,6 +455,44 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, color: var(--coral); font-weight: 700; } +/* Keep the Remove button above the stretched link so it stays clickable. */ +.rewards-card-heading form { position: relative; z-index: 2; } + +/* Single-card scan view */ +.scan-page { + display: flex; + flex-direction: column; + align-items: center; + gap: 22px; + padding: 12px 0 40px; +} +.scan-back { align-self: flex-start; } +.scan-card { + width: min(100%, 460px); + padding: clamp(24px, 6vw, 42px); + border: 1px solid var(--line); + border-radius: 28px; + background: #fff; + box-shadow: var(--shadow); + text-align: center; +} +.scan-store { margin: 4px 0 26px; font-size: clamp(1.3rem, 4vw, 1.7rem); } +.scan-barcode { + display: flex; + justify-content: center; + padding: 22px; + border-radius: 14px; + background: #fff; +} +.scan-barcode svg { + width: 100%; + height: auto; + max-width: 420px; +} +.scan-card .rewards-number { + margin-top: 18px; + font-size: 1rem; +} select { width: 100%; min-height: 46px;