diff --git a/Cargo.lock b/Cargo.lock index 7ea52b5..dc681d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "barcoders" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3826fb6e98ec72c0c0db8c9a40af4932d16793021027469857e84c1b50a1e8f" + [[package]] name = "base64" version = "0.13.1" @@ -1777,6 +1783,7 @@ dependencies = [ "argon2", "async-trait", "axum", + "barcoders", "base64 0.22.1", "futures-util", "hex", diff --git a/Cargo.toml b/Cargo.toml index 090ee35..7d48bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" argon2 = "0.5" async-trait = "0.1" axum = { version = "0.8", features = ["ws"] } +barcoders = { version = "2.0.0", features = ["svg"] } base64 = "0.22" futures-util = "0.3" hex = "0.4" diff --git a/README.md b/README.md index 6b29bfa..e6bc611 100644 --- a/README.md +++ b/README.md @@ -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 - 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 - 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 new file mode 100644 index 0000000..e482482 --- /dev/null +++ b/e2e/tests/rewards.spec.ts @@ -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(); +}); \ No newline at end of file diff --git a/migrations/20240105000000_rewards_cards.sql b/migrations/20240105000000_rewards_cards.sql new file mode 100644 index 0000000..f36d9f7 --- /dev/null +++ b/migrations/20240105000000_rewards_cards.sql @@ -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); \ No newline at end of file diff --git a/src/domain.rs b/src/domain.rs index eaa3359..4096c9e 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -113,3 +113,19 @@ pub struct PresenceUser { pub user_id: i64, 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, +} diff --git a/src/http.rs b/src/http.rs index 35e20a8..20928f0 100644 --- a/src/http.rs +++ b/src/http.rs @@ -23,7 +23,9 @@ use tracing::{Level, error, warn}; use crate::assets; use crate::domain::{DomainError, SessionUser}; 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::webauthn::WebAuthnService; @@ -33,6 +35,7 @@ pub struct AppState { pub lists: Arc, pub meals: Arc, pub invitations: Arc, + pub rewards_cards: Arc, pub webauthn: Arc, pub realtime: Arc, pub cookie_secure: bool, @@ -130,6 +133,8 @@ pub fn build_router(state: AppState) -> Router { "/lists/{list_id}/meals/{list_meal_id}/remove", 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("/invite/{token}", get(invitation_page)) .route("/invite/{token}/accept", post(accept_invitation)) @@ -261,6 +266,14 @@ struct CategoryForm { csrf: String, } +#[derive(Debug, Deserialize)] +struct RewardsCardForm { + store_name: String, + number: String, + symbology: String, + csrf: String, +} + #[derive(Debug, Deserialize)] struct PasskeyRegisterStartForm { csrf: String, @@ -819,6 +832,67 @@ async fn delete_meal_category( Ok(Redirect::to("/meals").into_response()) } +async fn rewards_page( + State(state): State, + user: CurrentUser, +) -> Result { + 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, + user: CurrentUser, + LoggedForm(form): LoggedForm, +) -> Result { + 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, + user: CurrentUser, + Path(card_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + 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( State(state): State, user: CurrentUser, diff --git a/src/main.rs b/src/main.rs index ab873d5..2208e27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,15 +22,18 @@ use crate::hub::InMemoryHub; use crate::ports::{ CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository, - PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, + PasswordHasher, RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator, + UserRepository, }; 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::{ SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository, SqliteListMealRepository, SqliteListRepository, SqliteMealCategoryRepository, SqliteMealIngredientRepository, SqliteMealRepository, SqlitePasskeyRepository, - SqliteSessionRepository, SqliteUserRepository, + SqliteRewardsCardRepository, SqliteSessionRepository, SqliteUserRepository, }; use crate::webauthn::{AppWebauthnConfig, WebAuthnService}; @@ -90,6 +93,7 @@ async fn main() -> Result<(), Box> { let meal_categories: Arc = Arc::new(SqliteMealCategoryRepository); let invitations: Arc = Arc::new(SqliteInvitationRepository); let passkeys: Arc = Arc::new(SqlitePasskeyRepository); + let rewards_cards: Arc = Arc::new(SqliteRewardsCardRepository); let hasher: Arc = Arc::new(Argon2PasswordHasher); let tokens: Arc = Arc::new(RandomTokenGenerator); let realtime: Arc = Arc::new(InMemoryHub::default()); @@ -114,6 +118,10 @@ async fn main() -> Result<(), Box> { Arc::clone(&invitations), Arc::clone(&tokens), )); + let rewards_cards_service = Arc::new(RewardsCardService::new( + db.clone(), + Arc::clone(&rewards_cards), + )); let meals_service = Arc::new(MealService::new( db.clone(), Arc::clone(&meals), @@ -157,6 +165,7 @@ async fn main() -> Result<(), Box> { lists: lists_service, meals: meals_service, invitations: invitations_service, + rewards_cards: rewards_cards_service, webauthn: webauthn_service, realtime, cookie_secure, diff --git a/src/ports.rs b/src/ports.rs index 2383424..fb4c9a6 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -3,7 +3,7 @@ use sqlx::SqliteConnection; use crate::domain::{ 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), @@ -314,3 +314,27 @@ pub enum HubEvent { ListChanged { list_id: i64, revision: 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>; + async fn create_card( + &self, + txn: &mut SqliteConnection, + user_id: i64, + store_name: String, + number: String, + symbology: String, + ) -> DomainResult; + async fn delete_card( + &self, + txn: &mut SqliteConnection, + user_id: i64, + card_id: i64, + ) -> DomainResult<()>; +} diff --git a/src/services.rs b/src/services.rs index 9fa5d9f..d988e00 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,12 +1,13 @@ use std::sync::Arc; 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::{ CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasswordHasher, - RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, + RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator, UserRepository, }; use crate::sqlite::SqliteDatabase; @@ -628,3 +629,47 @@ impl InvitationService { .await } } + +pub struct RewardsCardService { + db: SqliteDatabase, + cards: Arc, +} + +impl RewardsCardService { + pub fn new(db: SqliteDatabase, cards: Arc) -> Self { + Self { db, cards } + } + + pub async fn list_cards(&self, user_id: i64) -> DomainResult> { + 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 { + 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 + } +} diff --git a/src/sqlite.rs b/src/sqlite.rs index d4b791f..b921917 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -8,12 +8,12 @@ use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectO use crate::domain::{ Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, - MealIngredient, Passkey, SessionUser, User, + MealIngredient, Passkey, RewardsCard, SessionUser, User, }; use crate::ports::{ CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository, - SessionRepository, UserRepository, + RewardsCardRepository, SessionRepository, UserRepository, }; /// 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> { + 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 { + 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::(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) -> DomainResult<()> { let Some(category_id) = category_id else { return Ok(()); @@ -2786,4 +2874,119 @@ mod tests { .await; 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))); + } } diff --git a/src/views.rs b/src/views.rs index e825b4d..ec1534b 100644 --- a/src/views.rs +++ b/src/views.rs @@ -4,7 +4,8 @@ use pulldown_cmark::{Options, Parser, html as cmark_html}; use crate::{ domain::PresenceUser, 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" { 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); button class="danger-link bordered-delete" type="submit" { "Delete" } } @@ -743,6 +744,19 @@ fn render_markdown(source: &str) -> Markup { mod tests { use super::*; + /// Extracts the rendered SVG width from `viewBox="0 0 "`. + 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] fn render_markdown_turns_bullets_into_list_html() { let html = render_markdown("- one\n- two\n").into_string(); @@ -764,6 +778,76 @@ mod tests { let ts = 1_786_060_800; 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(""), "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(" 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"). fn format_date(timestamp: i64) -> String { 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" { a href="/lists" { "Lists" } a href="/meals" { "Meals" } + a href="/rewards" { "Rewards" } } div class="account-nav" { a class="user-name" href="/account" { (user.display_name) } diff --git a/static/style.css b/static/style.css index bf68d6c..afed54f 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } .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); }