add screen to view a barcode isolated
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -65,6 +65,7 @@ pub static STORE: LazyLock<StaticAssetStore> = 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",
|
||||
|
||||
+17
@@ -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<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(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
|
||||
@@ -323,6 +323,12 @@ pub trait RewardsCardRepository: Send + Sync {
|
||||
txn: &mut SqliteConnection,
|
||||
user_id: i64,
|
||||
) -> DomainResult<Vec<RewardsCard>>;
|
||||
async fn get_card(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
user_id: i64,
|
||||
card_id: i64,
|
||||
) -> DomainResult<Option<RewardsCard>>;
|
||||
async fn create_card(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
|
||||
@@ -648,6 +648,13 @@ impl RewardsCardService {
|
||||
.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(
|
||||
&self,
|
||||
user_id: i64,
|
||||
|
||||
+89
-11
@@ -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<Option<RewardsCard>> {
|
||||
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<i64>) -> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user