add reward card feature with barcodes
This commit is contained in:
+205
-2
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user