add screen to view a barcode isolated

This commit is contained in:
2026-08-08 16:35:54 -04:00
parent 35de0e1990
commit 58200b2bf9
10 changed files with 274 additions and 11 deletions
+1
View File
@@ -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 - Items grouped by category and assigned from the add/edit forms
- Meals with ingredients, markdown descriptions, and one-click "add meal to list" - 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 - 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 - Server-authoritative last-write-wins updates
- Per-list WebSocket updates with server-rendered htmx fragments - Per-list WebSocket updates with server-rendered htmx fragments
- In-memory presence for members currently viewing a list - In-memory presence for members currently viewing a list
+51
View File
@@ -116,3 +116,54 @@ test("cancelling the remove confirmation keeps the rewards card", async ({
await expect(page.locator(".rewards-card")).toHaveCount(1); await expect(page.locator(".rewards-card")).toHaveCount(1);
await expect(page.locator(".rewards-card").filter({ hasText: "Kroger" })).toBeVisible(); 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);
});
+1
View File
@@ -65,6 +65,7 @@ pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
"passkey-login.js" => "../static/passkey-login.js", "passkey-login.js" => "../static/passkey-login.js",
"passkey-register.js" => "../static/passkey-register.js", "passkey-register.js" => "../static/passkey-register.js",
"password-toggle.js" => "../static/password-toggle.js", "password-toggle.js" => "../static/password-toggle.js",
"rewards.js" => "../static/rewards.js",
"htmx.min.js" => "../static/htmx.min.js", "htmx.min.js" => "../static/htmx.min.js",
"idiomorph-ext.min.js" => "../static/idiomorph-ext.min.js", "idiomorph-ext.min.js" => "../static/idiomorph-ext.min.js",
"htmx-ws.min.js" => "../static/htmx-ws.min.js", "htmx-ws.min.js" => "../static/htmx-ws.min.js",
+17
View File
@@ -134,6 +134,7 @@ pub fn build_router(state: AppState) -> Router {
post(remove_meal_from_list), post(remove_meal_from_list),
) )
.route("/rewards", get(rewards_page).post(create_rewards_card)) .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("/rewards/{card_id}/delete", post(delete_rewards_card))
.route("/lists/{list_id}/stream", get(list_stream)) .route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page)) .route("/invite/{token}", get(invitation_page))
@@ -845,6 +846,22 @@ async fn rewards_page(
))) )))
} }
async fn rewards_scan_page(
State(state): State<AppState>,
user: CurrentUser,
Path(card_id): Path<i64>,
) -> Result<Response, AppError> {
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( async fn create_rewards_card(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
+6
View File
@@ -323,6 +323,12 @@ pub trait RewardsCardRepository: Send + Sync {
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
user_id: i64, user_id: i64,
) -> DomainResult<Vec<RewardsCard>>; ) -> DomainResult<Vec<RewardsCard>>;
async fn get_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
card_id: i64,
) -> DomainResult<Option<RewardsCard>>;
async fn create_card( async fn create_card(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
+7
View File
@@ -648,6 +648,13 @@ impl RewardsCardService {
.await .await
} }
pub async fn get_card(&self, user_id: i64, card_id: i64) -> DomainResult<Option<RewardsCard>> {
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( pub async fn create_card(
&self, &self,
user_id: i64, user_id: i64,
+89 -11
View File
@@ -1315,17 +1315,26 @@ impl RewardsCardRepository for SqliteRewardsCardRepository {
.fetch_all(&mut *txn) .fetch_all(&mut *txn)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
Ok(rows Ok(rows.into_iter().map(map_rewards_card_row).collect())
.into_iter() }
.map(|row| RewardsCard {
id: row.get(0), async fn get_card(
user_id: row.get(1), &self,
store_name: row.get(2), txn: &mut SqliteConnection,
number: row.get(3), user_id: i64,
symbology: row.get(4), card_id: i64,
created_at: row.get(5), ) -> DomainResult<Option<RewardsCard>> {
}) let row = sqlx::query(
.collect()) "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( 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<i64>) -> DomainResult<()> { async fn ensure_category(txn: &mut SqliteConnection, category_id: Option<i64>) -> DomainResult<()> {
let Some(category_id) = category_id else { let Some(category_id) = category_id else {
return Ok(()); return Ok(());
@@ -3045,4 +3066,61 @@ mod tests {
.await; .await;
assert!(matches!(result, Err(DomainError::NotFound))); 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());
}
} }
+24
View File
@@ -1434,6 +1434,7 @@ pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Mar
div class="rewards-grid" { div class="rewards-grid" {
@for card in cards { @for card in cards {
div class="rewards-card" { 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" { div class="rewards-card-heading" {
strong { (card.store_name) } strong { (card.store_name) }
form method="post" action=(format!("/rewards/{}/delete", card.id)) hx-confirm="Remove this rewards card? This cannot be undone." { 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 /// 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 /// 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). /// (for example, a Code 128 number that isn't valid for the chosen symbology).
+30
View File
@@ -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();
})();
+48
View File
@@ -412,10 +412,20 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
/* Rewards cards */ /* Rewards cards */
.rewards-grid { display: grid; gap: 16px; } .rewards-grid { display: grid; gap: 16px; }
.rewards-card { .rewards-card {
position: relative;
padding: 18px; padding: 18px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 18px;
background: #fff; 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 { .rewards-card-heading {
display: flex; display: flex;
@@ -445,6 +455,44 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
color: var(--coral); color: var(--coral);
font-weight: 700; 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 { select {
width: 100%; width: 100%;
min-height: 46px; min-height: 46px;