add reward card feature with barcodes
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
+75
-1
@@ -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<ListService>,
|
||||
pub meals: Arc<MealService>,
|
||||
pub invitations: Arc<InvitationService>,
|
||||
pub rewards_cards: Arc<RewardsCardService>,
|
||||
pub webauthn: Arc<WebAuthnService>,
|
||||
pub realtime: Arc<dyn RealtimeNotifier>,
|
||||
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<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(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
|
||||
+12
-3
@@ -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<dyn std::error::Error>> {
|
||||
let meal_categories: Arc<dyn MealCategoryRepository> = Arc::new(SqliteMealCategoryRepository);
|
||||
let invitations: Arc<dyn InvitationRepository> = Arc::new(SqliteInvitationRepository);
|
||||
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 tokens: Arc<dyn TokenGenerator> = Arc::new(RandomTokenGenerator);
|
||||
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(&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<dyn std::error::Error>> {
|
||||
lists: lists_service,
|
||||
meals: meals_service,
|
||||
invitations: invitations_service,
|
||||
rewards_cards: rewards_cards_service,
|
||||
webauthn: webauthn_service,
|
||||
realtime,
|
||||
cookie_secure,
|
||||
|
||||
+25
-1
@@ -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<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
@@ -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<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
@@ -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<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<()> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
+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