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