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
+205 -2
View File
@@ -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 <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]
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("<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(
@@ -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").
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) }