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
@@ -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
View File
@@ -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,
+6
View File
@@ -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,
+7
View File
@@ -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
View File
@@ -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());
}
}
+24
View File
@@ -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).