add reward card feature with barcodes

This commit is contained in:
2026-08-08 15:55:18 -04:00
parent ab46e63bf0
commit 975cf38170
13 changed files with 771 additions and 11 deletions
Generated
+7
View File
@@ -116,6 +116,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "barcoders"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3826fb6e98ec72c0c0db8c9a40af4932d16793021027469857e84c1b50a1e8f"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.13.1" version = "0.13.1"
@@ -1777,6 +1783,7 @@ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"axum", "axum",
"barcoders",
"base64 0.22.1", "base64 0.22.1",
"futures-util", "futures-util",
"hex", "hex",
+1
View File
@@ -7,6 +7,7 @@ edition = "2024"
argon2 = "0.5" argon2 = "0.5"
async-trait = "0.1" async-trait = "0.1"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
barcoders = { version = "2.0.0", features = ["svg"] }
base64 = "0.22" base64 = "0.22"
futures-util = "0.3" futures-util = "0.3"
hex = "0.4" hex = "0.4"
+1
View File
@@ -57,6 +57,7 @@ The file is optional — if it is missing or invalid, seeding is silently skippe
- Global categories with common defaults seeded at startup and custom category creation - Global categories with common defaults seeded at startup and custom category creation
- 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
- 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
+118
View File
@@ -0,0 +1,118 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import { registerAndLogin } from "../helpers";
/** Navigates to the rewards cards page. */
async function gotoRewards(page: import("@playwright/test").Page) {
await page.goto("/rewards");
await expect(page.locator("h1")).toContainText("Rewards cards");
}
/**
* Adds a rewards card and lands back on the /rewards page with it rendered.
*/
async function addCard(
page: import("@playwright/test").Page,
storeName: string,
number: string,
symbology?: string,
) {
await page.fill("#store-name", storeName);
await page.fill("#card-number", number);
if (symbology) {
await page.selectOption("#symbology", symbology);
}
await page.click('button:has-text("Add card")');
await expect(page).toHaveURL(/\/rewards$/);
const card = page.locator(".rewards-card").filter({ hasText: storeName });
await expect(card).toBeVisible();
return card;
}
test("the rewards page shows an empty state before any cards are added", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
await expect(page.locator(".empty-state")).toContainText("No rewards cards yet");
await expect(page.locator(".rewards-card")).toHaveCount(0);
});
test("the Rewards link is available in the site navigation", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
const nav = page.locator(".site-nav");
await expect(nav.locator('a[href="/rewards"]')).toHaveText("Rewards");
});
test("a user can add a rewards card and see its barcode", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
const card = await addCard(page, "Kroger", "606171584511340224537");
// The card renders an inline SVG barcode and the store's card number.
await expect(card.locator(".rewards-barcode svg")).toBeVisible();
await expect(card.locator(".rewards-number")).toHaveText("606171584511340224537");
// The barcode should be included via the embedded SVG (Code 128 set B→C mix),
// not rendered client-side from scratch by an image.
const svg = card.locator(".rewards-barcode svg");
await expect(svg).toHaveAttribute("viewBox", /\d+ \d+/);
});
test("a user can add a card as Code 39 and see its barcode", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
const card = await addCard(page, "Safeway", "ABC123", "code39");
await expect(card.locator(".rewards-barcode svg")).toBeVisible();
});
test("multiple rewards cards are each rendered with their own barcode", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
await addCard(page, "Kroger", "606171584511340224537");
await addCard(page, "Safeway", "012345678901");
await expect(page.locator(".rewards-card")).toHaveCount(2);
await expect(page.locator(".rewards-card").filter({ hasText: "Kroger" })).toBeVisible();
await expect(page.locator(".rewards-card").filter({ hasText: "Safeway" })).toBeVisible();
});
test("removing a rewards card prompts for confirmation and deletes on accept", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
await addCard(page, "Kroger", "606171584511340224537");
await expect(page).toHaveURL(/\/rewards$/);
await expect(page.locator(".rewards-card")).toHaveCount(1);
page.on("dialog", (dialog) => dialog.accept());
await page.click('button:has-text("Remove")');
// The card is deleted and the empty state returns.
await expect(page.locator(".rewards-card")).toHaveCount(0);
await expect(page.locator(".empty-state")).toContainText("No rewards cards yet");
});
test("cancelling the remove confirmation keeps the rewards card", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
await addCard(page, "Kroger", "606171584511340224537");
await expect(page.locator(".rewards-card")).toHaveCount(1);
page.on("dialog", (dialog) => dialog.dismiss());
await page.click('button:has-text("Remove")');
// The card must remain after cancelling.
await expect(page.locator(".rewards-card")).toHaveCount(1);
await expect(page.locator(".rewards-card").filter({ hasText: "Kroger" })).toBeVisible();
});
@@ -0,0 +1,10 @@
CREATE TABLE rewards_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
store_name TEXT NOT NULL,
number TEXT NOT NULL,
symbology TEXT NOT NULL DEFAULT 'code128',
created_at INTEGER NOT NULL
);
CREATE INDEX rewards_cards_user_idx ON rewards_cards(user_id);
+16
View File
@@ -113,3 +113,19 @@ pub struct PresenceUser {
pub user_id: i64, pub user_id: i64,
pub display_name: String, pub display_name: String,
} }
/// A stored rewards-card number that can be shown as a scannable barcode.
#[derive(Clone, Debug)]
pub struct RewardsCard {
pub id: i64,
/// The owner of this card.
#[allow(dead_code)]
pub user_id: i64,
pub store_name: String,
pub number: String,
/// Symbology used to render the barcode (e.g. "code128", "code39").
pub symbology: String,
/// When the card was added.
#[allow(dead_code)]
pub created_at: i64,
}
+75 -1
View File
@@ -23,7 +23,9 @@ use tracing::{Level, error, warn};
use crate::assets; use crate::assets;
use crate::domain::{DomainError, SessionUser}; use crate::domain::{DomainError, SessionUser};
use crate::ports::{HubEvent, RealtimeNotifier}; use crate::ports::{HubEvent, RealtimeNotifier};
use crate::services::{AuthService, InvitationService, ListService, MealService}; use crate::services::{
AuthService, InvitationService, ListService, MealService, RewardsCardService,
};
use crate::views; use crate::views;
use crate::webauthn::WebAuthnService; use crate::webauthn::WebAuthnService;
@@ -33,6 +35,7 @@ pub struct AppState {
pub lists: Arc<ListService>, pub lists: Arc<ListService>,
pub meals: Arc<MealService>, pub meals: Arc<MealService>,
pub invitations: Arc<InvitationService>, pub invitations: Arc<InvitationService>,
pub rewards_cards: Arc<RewardsCardService>,
pub webauthn: Arc<WebAuthnService>, pub webauthn: Arc<WebAuthnService>,
pub realtime: Arc<dyn RealtimeNotifier>, pub realtime: Arc<dyn RealtimeNotifier>,
pub cookie_secure: bool, pub cookie_secure: bool,
@@ -130,6 +133,8 @@ pub fn build_router(state: AppState) -> Router {
"/lists/{list_id}/meals/{list_meal_id}/remove", "/lists/{list_id}/meals/{list_meal_id}/remove",
post(remove_meal_from_list), post(remove_meal_from_list),
) )
.route("/rewards", get(rewards_page).post(create_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))
.route("/invite/{token}/accept", post(accept_invitation)) .route("/invite/{token}/accept", post(accept_invitation))
@@ -261,6 +266,14 @@ struct CategoryForm {
csrf: String, csrf: String,
} }
#[derive(Debug, Deserialize)]
struct RewardsCardForm {
store_name: String,
number: String,
symbology: String,
csrf: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PasskeyRegisterStartForm { struct PasskeyRegisterStartForm {
csrf: String, csrf: String,
@@ -819,6 +832,67 @@ async fn delete_meal_category(
Ok(Redirect::to("/meals").into_response()) Ok(Redirect::to("/meals").into_response())
} }
async fn rewards_page(
State(state): State<AppState>,
user: CurrentUser,
) -> Result<Response, AppError> {
let cards = state.rewards_cards.list_cards(user.session.user.id).await?;
Ok(html_response(views::rewards_page(
&user.session.user,
&cards,
&user.session.csrf_token,
)))
}
async fn create_rewards_card(
State(state): State<AppState>,
user: CurrentUser,
LoggedForm(form): LoggedForm<RewardsCardForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let store_name = form.store_name.trim().to_owned();
let number = form.number.trim().to_owned();
let symbology = normalize_symbology(&form.symbology);
if store_name.is_empty() || store_name.chars().count() > 60 {
return Err(AppError::BadRequest(
"Store names must be between 1 and 60 characters.".into(),
));
}
if number.is_empty() || number.chars().count() > 80 {
return Err(AppError::BadRequest(
"Card numbers must be between 1 and 80 characters.".into(),
));
}
state
.rewards_cards
.create_card(user.session.user.id, store_name, number, symbology)
.await?;
Ok(Redirect::to("/rewards").into_response())
}
async fn delete_rewards_card(
State(state): State<AppState>,
user: CurrentUser,
Path(card_id): Path<i64>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
state
.rewards_cards
.delete_card(user.session.user.id, card_id)
.await?;
Ok(Redirect::to("/rewards").into_response())
}
/// Normalizes a submitted symbology value to a known barcode type, falling
/// back to Code 128 for unknown or empty values.
fn normalize_symbology(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"code39" => "code39".to_owned(),
_ => "code128".to_owned(),
}
}
async fn meals_page( async fn meals_page(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
+12 -3
View File
@@ -22,15 +22,18 @@ use crate::hub::InMemoryHub;
use crate::ports::{ use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository,
PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, PasswordHasher, RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator,
UserRepository,
}; };
use crate::security::{Argon2PasswordHasher, RandomTokenGenerator}; use crate::security::{Argon2PasswordHasher, RandomTokenGenerator};
use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode}; use crate::services::{
AuthService, InvitationService, ListService, MealService, RegistrationMode, RewardsCardService,
};
use crate::sqlite::{ use crate::sqlite::{
SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository, SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository,
SqliteListMealRepository, SqliteListRepository, SqliteMealCategoryRepository, SqliteListMealRepository, SqliteListRepository, SqliteMealCategoryRepository,
SqliteMealIngredientRepository, SqliteMealRepository, SqlitePasskeyRepository, SqliteMealIngredientRepository, SqliteMealRepository, SqlitePasskeyRepository,
SqliteSessionRepository, SqliteUserRepository, SqliteRewardsCardRepository, SqliteSessionRepository, SqliteUserRepository,
}; };
use crate::webauthn::{AppWebauthnConfig, WebAuthnService}; use crate::webauthn::{AppWebauthnConfig, WebAuthnService};
@@ -90,6 +93,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let meal_categories: Arc<dyn MealCategoryRepository> = Arc::new(SqliteMealCategoryRepository); let meal_categories: Arc<dyn MealCategoryRepository> = Arc::new(SqliteMealCategoryRepository);
let invitations: Arc<dyn InvitationRepository> = Arc::new(SqliteInvitationRepository); let invitations: Arc<dyn InvitationRepository> = Arc::new(SqliteInvitationRepository);
let passkeys: Arc<dyn PasskeyRepository> = Arc::new(SqlitePasskeyRepository); let passkeys: Arc<dyn PasskeyRepository> = Arc::new(SqlitePasskeyRepository);
let rewards_cards: Arc<dyn RewardsCardRepository> = Arc::new(SqliteRewardsCardRepository);
let hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher); let hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
let tokens: Arc<dyn TokenGenerator> = Arc::new(RandomTokenGenerator); let tokens: Arc<dyn TokenGenerator> = Arc::new(RandomTokenGenerator);
let realtime: Arc<dyn RealtimeNotifier> = Arc::new(InMemoryHub::default()); let realtime: Arc<dyn RealtimeNotifier> = Arc::new(InMemoryHub::default());
@@ -114,6 +118,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Arc::clone(&invitations), Arc::clone(&invitations),
Arc::clone(&tokens), Arc::clone(&tokens),
)); ));
let rewards_cards_service = Arc::new(RewardsCardService::new(
db.clone(),
Arc::clone(&rewards_cards),
));
let meals_service = Arc::new(MealService::new( let meals_service = Arc::new(MealService::new(
db.clone(), db.clone(),
Arc::clone(&meals), Arc::clone(&meals),
@@ -157,6 +165,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
lists: lists_service, lists: lists_service,
meals: meals_service, meals: meals_service,
invitations: invitations_service, invitations: invitations_service,
rewards_cards: rewards_cards_service,
webauthn: webauthn_service, webauthn: webauthn_service,
realtime, realtime,
cookie_secure, cookie_secure,
+25 -1
View File
@@ -3,7 +3,7 @@ use sqlx::SqliteConnection;
use crate::domain::{ use crate::domain::{
Category, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Category, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient,
Passkey, PresenceUser, SessionUser, User, Passkey, PresenceUser, RewardsCard, SessionUser, User,
}; };
/// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to), /// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to),
@@ -314,3 +314,27 @@ pub enum HubEvent {
ListChanged { list_id: i64, revision: i64 }, ListChanged { list_id: i64, revision: i64 },
PresenceChanged { list_id: i64 }, PresenceChanged { list_id: i64 },
} }
/// Stores and retrieves a user's rewards cards.
#[async_trait]
pub trait RewardsCardRepository: Send + Sync {
async fn list_cards(
&self,
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<Vec<RewardsCard>>;
async fn create_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
store_name: String,
number: String,
symbology: String,
) -> DomainResult<RewardsCard>;
async fn delete_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
card_id: i64,
) -> DomainResult<()>;
}
+47 -2
View File
@@ -1,12 +1,13 @@
use std::sync::Arc; use std::sync::Arc;
use crate::domain::{ use crate::domain::{
DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, SessionUser, User, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, RewardsCard,
SessionUser, User,
}; };
use crate::ports::{ use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasswordHasher, MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasswordHasher,
RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator, UserRepository,
}; };
use crate::sqlite::SqliteDatabase; use crate::sqlite::SqliteDatabase;
@@ -628,3 +629,47 @@ impl InvitationService {
.await .await
} }
} }
pub struct RewardsCardService {
db: SqliteDatabase,
cards: Arc<dyn RewardsCardRepository>,
}
impl RewardsCardService {
pub fn new(db: SqliteDatabase, cards: Arc<dyn RewardsCardRepository>) -> Self {
Self { db, cards }
}
pub async fn list_cards(&self, user_id: i64) -> DomainResult<Vec<RewardsCard>> {
let cards = Arc::clone(&self.cards);
self.db
.run(move |txn| Box::pin(async move { cards.list_cards(txn, user_id).await }))
.await
}
pub async fn create_card(
&self,
user_id: i64,
store_name: String,
number: String,
symbology: String,
) -> DomainResult<RewardsCard> {
let cards = Arc::clone(&self.cards);
self.db
.run(move |txn| {
Box::pin(async move {
cards
.create_card(txn, user_id, store_name, number, symbology)
.await
})
})
.await
}
pub async fn delete_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.delete_card(txn, user_id, card_id).await }))
.await
}
}
+205 -2
View File
@@ -8,12 +8,12 @@ use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectO
use crate::domain::{ use crate::domain::{
Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory,
MealIngredient, Passkey, SessionUser, User, MealIngredient, Passkey, RewardsCard, SessionUser, User,
}; };
use crate::ports::{ use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository,
SessionRepository, UserRepository, RewardsCardRepository, SessionRepository, UserRepository,
}; };
/// The embedded SQL migrations, applied automatically on startup. /// The embedded SQL migrations, applied automatically on startup.
@@ -1292,6 +1292,94 @@ impl MealIngredientRepository for SqliteMealIngredientRepository {
} }
} }
#[derive(Clone, Copy)]
pub struct SqliteRewardsCardRepository;
#[async_trait]
impl RewardsCardRepository for SqliteRewardsCardRepository {
async fn list_cards(
&self,
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<Vec<RewardsCard>> {
let rows = sqlx::query(
"SELECT id, user_id, store_name, number, symbology, created_at
FROM rewards_cards
WHERE user_id = ?1
ORDER BY created_at DESC, id DESC",
)
.bind(user_id)
.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())
}
async fn create_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
store_name: String,
number: String,
symbology: String,
) -> DomainResult<RewardsCard> {
sqlx::query(
"INSERT INTO rewards_cards (user_id, store_name, number, symbology, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(user_id)
.bind(&store_name)
.bind(&number)
.bind(&symbology)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
let card_id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(RewardsCard {
id: card_id,
user_id,
store_name,
number,
symbology,
created_at: now(),
})
}
async fn delete_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
card_id: i64,
) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM rewards_cards WHERE id = ?1 AND user_id = ?2")
.bind(card_id)
.bind(user_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
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(());
@@ -2786,4 +2874,119 @@ mod tests {
.await; .await;
assert!(matches!(result, Err(DomainError::NotFound))); assert!(matches!(result, Err(DomainError::NotFound)));
} }
// ---- RewardsCardRepository ----
#[tokio::test]
async fn rewards_card_crud_roundtrip() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let cards = SqliteRewardsCardRepository;
let created = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move {
cards
.create_card(
txn,
user.id,
"Kroger".into(),
"6011 2345 6789".into(),
"code128".into(),
)
.await
})
})
.await
.unwrap();
assert!(created.id > 0);
assert_eq!(created.user_id, user.id);
assert_eq!(created.store_name, "Kroger");
assert_eq!(created.number, "6011 2345 6789");
assert_eq!(created.symbology, "code128");
let listed = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.list_cards(txn, user.id).await })
})
.await
.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, created.id);
db.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.delete_card(txn, user.id, created.id).await })
})
.await
.unwrap();
let after = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.list_cards(txn, user.id).await })
})
.await
.unwrap();
assert!(after.is_empty());
}
#[tokio::test]
async fn rewards_cards_are_scoped_to_their_user() {
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;
db.run(move |txn| {
let cards = cards.clone();
Box::pin(async move {
cards
.create_card(
txn,
alice.id,
"Kroger".into(),
"123".into(),
"code128".into(),
)
.await
})
})
.await
.unwrap();
let bobs = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.list_cards(txn, bob.id).await })
})
.await
.unwrap();
assert!(bobs.is_empty());
// Bob cannot delete Alice's card.
let result = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.delete_card(txn, bob.id, 1).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn delete_missing_rewards_card_fails() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let cards = SqliteRewardsCardRepository;
let result = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.delete_card(txn, user.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
} }
+205 -2
View File
@@ -4,7 +4,8 @@ use pulldown_cmark::{Options, Parser, html as cmark_html};
use crate::{ use crate::{
domain::PresenceUser, domain::PresenceUser,
domain::{ domain::{
Category, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Passkey, User, Category, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Passkey,
RewardsCard, User,
}, },
}; };
@@ -585,7 +586,7 @@ pub fn meal_page(
} }
div class="list-topbar-actions" { div class="list-topbar-actions" {
button type="button" class="button button-small button-quiet" onclick="document.getElementById('meal-edit-modal').showModal()" { "Edit" } button type="button" class="button button-small button-quiet" onclick="document.getElementById('meal-edit-modal').showModal()" { "Edit" }
form method="post" action=(format!("/meals/{}/delete", meal.id)) onsubmit="return confirm('Delete this meal and its ingredients? This cannot be undone.')" { form method="post" action=(format!("/meals/{}/delete", meal.id)) hx-confirm="Delete this meal and its ingredients? This cannot be undone." {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link bordered-delete" type="submit" { "Delete" } button class="danger-link bordered-delete" type="submit" { "Delete" }
} }
@@ -743,6 +744,19 @@ fn render_markdown(source: &str) -> Markup {
mod tests { mod tests {
use super::*; use super::*;
/// Extracts the rendered SVG width from `viewBox="0 0 <width> <height>"`.
fn svg_width(svg: &str) -> u32 {
let viewbox = svg
.split("viewBox=\"")
.nth(1)
.expect("expected viewBox")
.split('"')
.next()
.unwrap();
let width = viewbox.split_whitespace().nth(2).expect("expected width");
width.parse().expect("expected numeric width")
}
#[test] #[test]
fn render_markdown_turns_bullets_into_list_html() { fn render_markdown_turns_bullets_into_list_html() {
let html = render_markdown("- one\n- two\n").into_string(); let html = render_markdown("- one\n- two\n").into_string();
@@ -764,6 +778,76 @@ mod tests {
let ts = 1_786_060_800; let ts = 1_786_060_800;
assert_eq!(format_date(ts), "7 Aug 2026"); assert_eq!(format_date(ts), "7 Aug 2026");
} }
#[test]
fn barcode_svg_renders_code128_svg() {
let html = barcode_svg("code128", "601123456789").into_string();
assert!(html.contains("<svg"), "expected svg, got: {html}");
assert!(html.contains("</svg>"), "expected closing svg, got: {html}");
}
#[test]
fn code128_uses_set_c_for_even_digit_numbers() {
// Set C (Ć) packs two digits per symbol, so an even-length digit-only
// number should be encoded with set C rather than set B.
assert_eq!(code128_input("601123456789"), "Ć601123456789");
// Non-numeric data falls back to set B.
assert_eq!(code128_input("ABC123"), "ƁABC123");
assert_eq!(code128_input(""), "Ɓ");
}
#[test]
fn code128_odd_digit_numbers_lead_with_set_b_then_switch_to_set_c() {
// 21-digit number: first digit in set B, then set C for the rest.
assert_eq!(
code128_input("606171584511340224537"),
"Ɓ6Ć06171584511340224537"
);
}
#[test]
fn code128_set_c_renders_shorter_than_set_b() {
let number = "601123456789";
let set_c = barcode_svg("code128", number).into_string();
// Force set B by using a non-numeric character so the digit-only
// fast path doesn't kick in.
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
let width_c = svg_width(&set_c);
let width_b = svg_width(&set_b);
assert!(
width_c < width_b,
"expected set C ({width_c}) narrower than set B ({width_b})"
);
}
#[test]
fn code128_odd_digit_number_renders_shorter_than_pure_set_b() {
let number = "606171584511340224537";
let mixed = barcode_svg("code128", number).into_string();
// Force pure set B by appending a non-numeric character.
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
let width_mixed = svg_width(&mixed);
let width_b = svg_width(&set_b);
assert!(
width_mixed < width_b,
"expected mixed ({width_mixed}) narrower than set B ({width_b})"
);
}
#[test]
fn barcode_svg_renders_code39_svg() {
let html = barcode_svg("code39", "ABC123").into_string();
assert!(html.contains("<svg"), "expected svg, got: {html}");
}
#[test]
fn barcode_svg_falls_back_to_text_for_invalid_number() {
// Code 39 only supports uppercase letters and digits, so lowercase
// input cannot be encoded and falls back to plain text.
let html = barcode_svg("code39", "abc").into_string();
assert!(!html.contains("<svg"), "expected no svg, got: {html}");
assert!(html.contains("abc"), "expected fallback text, got: {html}");
}
} }
pub fn list_page( pub fn list_page(
@@ -1260,6 +1344,124 @@ pub fn invite_result(url: &str) -> Markup {
} }
} }
pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Markup {
page(
"Rewards cards",
Some(user),
html! {
div class="page-heading" {
div {
p class="eyebrow" { "REWARDS CARDS" }
h1 class="page-title" { "Rewards cards" }
p class="lede" { "Your store cards, ready to scan." }
}
}
div class="dashboard-grid" {
section class="panel" {
div class="panel-heading" {
h2 { "Your cards" }
span class="count-badge" { (cards.len()) }
}
@if cards.is_empty() {
div class="empty-state" {
div class="empty-mark" { "🏷" }
h3 { "No rewards cards yet" }
p { "Add a card to see its barcode here." }
}
} @else {
div class="rewards-grid" {
@for card in cards {
div class="rewards-card" {
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." {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link bordered-delete" type="submit" { "Remove" }
}
}
div class="rewards-barcode" {
(barcode_svg(&card.symbology, &card.number))
}
p class="rewards-number" { (card.number) }
}
}
}
}
}
section class="panel create-panel" {
div class="panel-heading" { h2 { "Add a card" } }
form method="post" action="/rewards" class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label for="store-name" { "Store name" }
input id="store-name" name="store_name" type="text" maxlength="60" placeholder="e.g. Kroger" required;
label for="card-number" { "Card number" }
input id="card-number" name="number" type="text" maxlength="80" placeholder="e.g. 6011 2345 6789" required;
label for="symbology" { "Barcode type" }
select id="symbology" name="symbology" {
option value="code128" selected { "Code 128 (recommended)" }
option value="code39" { "Code 39" }
}
button class="button button-primary" type="submit" { "Add card" }
}
}
}
},
)
}
/// 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).
fn barcode_svg(symbology: &str, number: &str) -> Markup {
let generated = match symbology {
"code39" => barcoders::sym::code39::Code39::new(number)
.ok()
.map(|code| code.encode())
.and_then(|encoded| {
barcoders::generators::svg::SVG::new(60)
.generate(&encoded)
.ok()
}),
_ => barcoders::sym::code128::Code128::new(&code128_input(number))
.ok()
.map(|code| code.encode())
.and_then(|encoded| {
barcoders::generators::svg::SVG::new(60)
.generate(&encoded)
.ok()
}),
};
match generated {
Some(svg) => html! { (maud::PreEscaped(svg)) },
None => html! { span class="rewards-unencodable" { (number) } },
}
}
/// Builds the Code 128 input string, choosing the most compact character set.
///
/// Code 128 set C packs two digits per symbol, so numeric data renders roughly
/// half as wide as set B (one character per symbol). Google Wallet (via ZXing)
/// picks set C automatically for numeric runs, so we do the same to keep the
/// rendered barcode compact and consistent with it:
///
/// - Even-length digit-only numbers: all set C (`Ć` prefix).
/// - Odd-length digit-only numbers: first digit in set B, then switch to set C
/// for the remaining even count of digits (e.g. `Ɓ6Ć0171...`).
/// - Anything else: set B (`Ɓ` prefix).
fn code128_input(number: &str) -> String {
let is_all_digits = !number.is_empty() && number.chars().all(|c| c.is_ascii_digit());
if !is_all_digits {
return format!("Ɓ{number}");
}
let len = number.chars().count();
if len % 2 == 0 {
format!("Ć{number}")
} else {
let (first, rest) = number.split_at(1);
format!("Ɓ{first}Ć{rest}")
}
}
/// Formats a Unix timestamp as a human-readable date (e.g. "7 Aug 2026"). /// Formats a Unix timestamp as a human-readable date (e.g. "7 Aug 2026").
fn format_date(timestamp: i64) -> String { fn format_date(timestamp: i64) -> String {
let days = timestamp.div_euclid(86_400); let days = timestamp.div_euclid(86_400);
@@ -1323,6 +1525,7 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
nav class="site-nav" { nav class="site-nav" {
a href="/lists" { "Lists" } a href="/lists" { "Lists" }
a href="/meals" { "Meals" } a href="/meals" { "Meals" }
a href="/rewards" { "Rewards" }
} }
div class="account-nav" { div class="account-nav" {
a class="user-name" href="/account" { (user.display_name) } a class="user-name" href="/account" { (user.display_name) }
+49
View File
@@ -403,3 +403,52 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
.side-column { grid-template-columns: 1fr; } .side-column { grid-template-columns: 1fr; }
.site-footer { margin-bottom: 20px; } .site-footer { margin-bottom: 20px; }
} }
/* Rewards cards */
.rewards-grid { display: grid; gap: 16px; }
.rewards-card {
padding: 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: #fff;
}
.rewards-card-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.rewards-card-heading strong { font-size: 1.05rem; }
.rewards-barcode {
display: flex;
justify-content: center;
padding: 14px;
border-radius: 12px;
background: #fff;
}
.rewards-barcode svg { width: 100%; height: auto; max-width: 340px; }
.rewards-number {
margin: 12px 0 0;
text-align: center;
color: var(--muted);
font-size: .85rem;
letter-spacing: .08em;
}
.rewards-unencodable {
display: block;
color: var(--coral);
font-weight: 700;
}
select {
width: 100%;
min-height: 46px;
padding: 10px 13px;
border: 1px solid var(--line);
border-radius: 12px;
outline: none;
color: var(--ink);
background: #fff;
font: inherit;
}
select:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); }