11 Commits
Author SHA1 Message Date
sbstp b77fb18398 version 0.9.0 [skip ci]
ci/woodpecker/tag/release Pipeline was successful
2026-08-09 21:09:55 -04:00
sbstp 2fe8f18589 meal eaten checkbox
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-09 21:07:17 -04:00
sbstp a1076a0731 run clippy & fix
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-08 22:53:11 -04:00
sbstp 20c2afb5ad simplify static assets 2026-08-08 22:41:50 -04:00
sbstp de46f150f8 version 0.8.0 [skip ci]
ci/woodpecker/tag/release Pipeline was successful
2026-08-08 22:09:28 -04:00
sbstp 77aac1e6ba add meal carry over feature
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-08 21:47:26 -04:00
sbstp 950add40a3 tag cmd should also push [skip ci] 2026-08-08 16:46:48 -04:00
sbstp e77c546cb2 version 0.7.0 [skip ci]
ci/woodpecker/tag/release Pipeline was successful
2026-08-08 16:45:37 -04:00
sbstp 67e8520b08 add justfile with useful commands 2026-08-08 16:45:25 -04:00
sbstp 842c4c9b0c fix ci
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-08 16:37:07 -04:00
sbstp 58200b2bf9 add screen to view a barcode isolated 2026-08-08 16:35:54 -04:00
19 changed files with 1294 additions and 257 deletions
+1
View File
@@ -6,6 +6,7 @@ steps:
image: rust:1 image: rust:1
commands: commands:
- cargo test --all - cargo test --all
- cargo build --all
e2e-test: e2e-test:
image: node:24 image: node:24
Generated
+1 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sustenance" name = "sustenance"
version = "0.4.0" version = "0.9.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "sustenance" name = "sustenance"
version = "0.4.0" version = "0.9.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1
View File
@@ -58,6 +58,7 @@ The file is optional — if it is missing or invalid, seeding is silently skippe
- 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 - Rewards cards with store name and number, rendered as scannable Code 128 / Code 39 barcodes
- Single-card scan view that shows one barcode at a time and keeps the screen awake for scanning
- 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
+28
View File
@@ -96,3 +96,31 @@ test("removing a meal from a list removes its ingredients", async ({ page }) =>
await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0); await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0);
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
}); });
test("a meal can be checked off without removing it from the list", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Spaghetti Bolognese", [{ name: "Penne" }]);
await createList(page, "Weekly shop");
await page.click(".add-meal-button");
const picker = page.locator(".meal-picker-backdrop");
await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click();
const panel = page.locator("#list-meals-panel");
const row = panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" });
await expect(row).toBeVisible();
await expect(row.locator(".list-meal-check-button")).not.toHaveClass(/is-.*checked/);
// Check the meal off as eaten.
await row.locator(".list-meal-check-button").click();
await expect(row).toHaveClass(/is-checked/);
await expect(row.locator(".list-meal-check-button")).toHaveText("✓");
// The meal is still on the list, not removed.
await expect(row).toBeVisible();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
// Unchecking restores it.
await row.locator(".list-meal-check-button").click();
await expect(row).not.toHaveClass(/is-checked/);
await expect(row).toBeVisible();
});
+134
View File
@@ -0,0 +1,134 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import { registerAndLogin, createList, createMealWithIngredients } from "../helpers";
/**
* Opens the carry-over modal and waits for htmx to finish swapping it in, so
* the source `<select>`'s `change` trigger is bound before interacting with it.
*/
async function openCarryModal(page: import("@playwright/test").Page) {
await page.click(".carry-over-button");
const carry = page.locator(".meal-picker-backdrop");
await expect(carry).toBeVisible();
// Let htmx finish processing the freshly-swapped modal before selecting a
// source; otherwise the change event can be missed and no hx-get fires.
await page.waitForTimeout(300);
return carry;
}
test("a user can carry meals over from a previous list without re-adding ingredients", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Spaghetti Bolognese", [
{ name: "Penne", quantity: "500g" },
{ name: "Tomato", quantity: "2" },
]);
// Last week's list has the meal added (with its ingredients as items).
await createList(page, "Last week");
await page.click(".add-meal-button");
const picker = page.locator(".meal-picker-backdrop");
await picker
.locator(".meal-picker-button")
.filter({ hasText: "Spaghetti Bolognese" })
.click();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
// Create this week's (new) list.
await createList(page, "This week");
await expect(page.locator(".item-row")).toHaveCount(0);
// Open the carry-over modal and pick the source list.
const carry = await openCarryModal(page);
await expect(carry.locator(".carry-intro")).toContainText("already purchased");
await carry.locator("#carry-source").selectOption({ label: "Last week" });
// The source list's meals appear as selectable rows.
const row = carry.locator(".carry-row").filter({ hasText: "Spaghetti Bolognese" });
await expect(row).toBeVisible();
// Select the meal and finish.
await row.locator('input[type="checkbox"]').check();
await carry.locator(".carry-submit").click();
// The modal closes, the meal appears in the meals panel, but no items are added.
await expect(carry).toHaveCount(0);
const panel = page.locator("#list-meals-panel");
await expect(panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
await expect(page.locator(".item-row")).toHaveCount(0);
});
test("the carry-over modal lists active lists newest first and excludes the current list", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await createList(page, "First list");
await createList(page, "Second list");
// Open carry-over on the second (current) list.
await page.click(".carry-over-button");
const carry = page.locator(".meal-picker-backdrop");
await expect(carry).toBeVisible();
const options = carry.locator("#carry-source option");
// The current list is excluded; only the other list remains.
await expect(options).toHaveCount(2); // placeholder + one source
await expect(carry.locator("#carry-source")).toContainText("First list");
await expect(carry.locator("#carry-source")).not.toContainText("Second list");
});
test("carrying a meal does not remove it from the source list", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Pasta", [{ name: "Penne" }]);
await createList(page, "Last week");
await page.click(".add-meal-button");
await page
.locator(".meal-picker-backdrop .meal-picker-button")
.filter({ hasText: "Pasta" })
.click();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
await createList(page, "This week");
const carry = await openCarryModal(page);
// Select the source list; the meal rows load via hx-get.
await carry.locator("#carry-source").selectOption({ label: "Last week" });
const row = carry.locator(".carry-row").filter({ hasText: "Pasta" });
await expect(row).toBeVisible({ timeout: 10_000 });
await row.locator('input[type="checkbox"]').check();
await carry.locator(".carry-submit").click();
await expect(carry).toHaveCount(0);
// The source list still has its meal and items.
await page.goto("/lists");
await page.locator(".list-card").filter({ hasText: "Last week" }).click();
await expect(page.locator("#list-meals-panel .list-meal-row").filter({ hasText: "Pasta" })).toBeVisible();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
});
test("submitting carry-over with no selection does not add a meal", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Pasta", [{ name: "Penne" }]);
await createList(page, "Last week");
await page.click(".add-meal-button");
await page
.locator(".meal-picker-backdrop .meal-picker-button")
.filter({ hasText: "Pasta" })
.click();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
await createList(page, "This week");
const carry = await openCarryModal(page);
// Select the source list; the meal rows load via hx-get.
await carry.locator("#carry-source").selectOption({ label: "Last week" });
await expect(carry.locator(".carry-row").filter({ hasText: "Pasta" })).toBeVisible({
timeout: 10_000,
});
// Submit with nothing selected; the modal stays open and no meal is carried.
await carry.locator(".carry-submit").click();
await expect(carry).toBeVisible();
await expect(page.locator("#list-meals-panel .list-meal-row")).toHaveCount(0);
});
+51
View File
@@ -116,3 +116,54 @@ test("cancelling the remove confirmation keeps the rewards card", async ({
await expect(page.locator(".rewards-card")).toHaveCount(1); await expect(page.locator(".rewards-card")).toHaveCount(1);
await expect(page.locator(".rewards-card").filter({ hasText: "Kroger" })).toBeVisible(); await expect(page.locator(".rewards-card").filter({ hasText: "Kroger" })).toBeVisible();
}); });
test("a user can open a single-card scan view with only one barcode", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
await addCard(page, "Kroger", "606171584511340224537");
await addCard(page, "Safeway", "012345678901");
// Click the Kroger card's barcode to open its focused scan view.
await page
.locator(".rewards-card")
.filter({ hasText: "Kroger" })
.locator(".rewards-card-link")
.click();
await expect(page).toHaveURL(/\/rewards\/\d+/);
// Only one barcode is rendered on the scan page.
await expect(page.locator(".scan-barcode svg")).toHaveCount(1);
await expect(page.locator(".scan-store")).toHaveText("Kroger");
await expect(page.locator(".scan-card .rewards-number")).toHaveText(
"606171584511340224537",
);
// The wake-lock script is loaded on the scan page.
await expect(page.locator('script[src*="rewards.js"]')).toHaveCount(1);
// Back link returns to the list.
await page.click('.scan-back');
await expect(page).toHaveURL(/\/rewards$/);
});
test("a scan view for another user's card is not accessible", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await gotoRewards(page);
await addCard(page, "Kroger", "606171584511340224537");
// Grab the card id from the URL of the scan view.
await page
.locator(".rewards-card-link")
.click();
const url = page.url();
const cardId = url.split("/").pop();
// Sign out and sign in as a different user.
await page.click('button:has-text("Sign out")');
await registerAndLogin(page, "bob@example.com");
// Bob cannot view Alice's card.
await page.goto(`/rewards/${cardId}`);
await expect(page.locator(".scan-barcode svg")).toHaveCount(0);
});
+39
View File
@@ -0,0 +1,39 @@
# Set the Cargo.toml version, commit it, and tag the commit with that version.
# Usage: just tag 1.2.3
tag ver:
#!/usr/bin/env bash
set -euo pipefail
# Validate the version argument.
if [[ ! "{{ver}}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "error: version must be in the form X.Y.Z (got '{{ver}}')" >&2
exit 1
fi
# Bump the version in Cargo.toml.
sed -i -E "s/^version = \".*\"/version = \"{{ver}}\"/" Cargo.toml
# Regenerate Cargo.lock so it reflects the new version.
cargo build
# Stage, commit, and tag.
git add Cargo.toml Cargo.lock
git commit -m "version {{ver}} [skip ci]"
git tag "{{ver}}"
git push
git push --tags
# Run the Rust unit tests.
# Usage: just test
test:
cargo test --all
# Format the Rust code.
# Usage: just fmt
fmt:
cargo fmt --all
# Run the end-to-end Playwright tests.
# Usage: just e2e
e2e:
cd e2e && npm test
@@ -0,0 +1 @@
ALTER TABLE list_meals ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
+27 -15
View File
@@ -48,12 +48,23 @@ fn content_hash(data: &[u8]) -> String {
hex::encode(hasher.finalize()) hex::encode(hasher.finalize())
} }
/// Constructs a `StaticAssetStore` from `name => path` pairs, embedding each /// Returns the file name portion of a path, e.g. `"../static/style.css"`
/// file's bytes at compile time via `include_bytes!`. /// becomes `"style.css"`. Used to derive an asset's registry key from its
/// path.
fn file_name(path: &'static str) -> &'static str {
match path.rfind('/') {
Some(i) => &path[i + 1..],
None => path,
}
}
/// Constructs a `StaticAssetStore` from paths, embedding each file's bytes at
/// compile time via `include_bytes!`. Each asset is keyed by its file name
/// (derived from the path), so there's no need to repeat the name.
macro_rules! static_assets { macro_rules! static_assets {
($($name:literal => $path:literal),* $(,)?) => { ($($path:literal),* $(,)?) => {
StaticAssetStore::new(&[ StaticAssetStore::new(&[
$(($name, include_bytes!($path))),* $((file_name($path), include_bytes!($path))),*
]) ])
}; };
} }
@@ -61,17 +72,18 @@ macro_rules! static_assets {
/// The app's static assets, loaded once on first use. /// The app's static assets, loaded once on first use.
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| { pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
static_assets! { static_assets! {
"style.css" => "../static/style.css", "../static/style.css",
"passkey-login.js" => "../static/passkey-login.js", "../static/passkey-login.js",
"passkey-register.js" => "../static/passkey-register.js", "../static/passkey-register.js",
"password-toggle.js" => "../static/password-toggle.js", "../static/password-toggle.js",
"htmx.min.js" => "../static/htmx.min.js", "../static/rewards.js",
"idiomorph-ext.min.js" => "../static/idiomorph-ext.min.js", "../static/htmx.min.js",
"htmx-ws.min.js" => "../static/htmx-ws.min.js", "../static/idiomorph-ext.min.js",
"favicon.ico" => "../static/favicon.ico", "../static/htmx-ws.min.js",
"favicon-32x32.png" => "../static/favicon-32x32.png", "../static/favicon.ico",
"apple-touch-icon.png" => "../static/apple-touch-icon.png", "../static/favicon-32x32.png",
"logo.svg" => "../static/logo.svg", "../static/apple-touch-icon.png",
"../static/logo.svg",
} }
}); });
+2
View File
@@ -103,6 +103,8 @@ pub struct ListMeal {
#[allow(dead_code)] #[allow(dead_code)]
pub meal_id: Option<i64>, pub meal_id: Option<i64>,
pub name: String, pub name: String,
/// Whether the meal has been eaten (checked off) on this list.
pub checked: bool,
/// When the meal was added to the list. /// When the meal was added to the list.
#[allow(dead_code)] #[allow(dead_code)]
pub created_at: i64, pub created_at: i64,
+143
View File
@@ -133,7 +133,17 @@ 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(
"/lists/{list_id}/meals/{list_meal_id}/check",
post(check_list_meal),
)
.route(
"/lists/{list_id}/carry",
get(carry_over_modal).post(carry_meals),
)
.route("/lists/{list_id}/carry/meals", get(carry_source_meals))
.route("/rewards", get(rewards_page).post(create_rewards_card)) .route("/rewards", get(rewards_page).post(create_rewards_card))
.route("/rewards/{card_id}", get(rewards_scan_page))
.route("/rewards/{card_id}/delete", post(delete_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))
@@ -337,6 +347,21 @@ struct AddMealForm {
csrf: String, csrf: String,
} }
#[derive(Debug, Deserialize)]
struct CarrySourceQuery {
source: i64,
}
#[derive(Debug, Deserialize)]
struct CarryMealsForm {
source_list_id: i64,
/// Selected list_meal ids as a comma-separated string, since the form
/// extractor does not coalesce repeated keys into a Vec.
#[serde(default)]
list_meal_ids: String,
csrf: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct MealPickerQuery { struct MealPickerQuery {
picker: Option<i64>, picker: Option<i64>,
@@ -845,6 +870,22 @@ async fn rewards_page(
))) )))
} }
async fn rewards_scan_page(
State(state): State<AppState>,
user: CurrentUser,
Path(card_id): Path<i64>,
) -> Result<Response, AppError> {
let card = state
.rewards_cards
.get_card(user.session.user.id, card_id)
.await?
.ok_or(AppError::NotFound)?;
Ok(html_response(views::rewards_scan_page(
&user.session.user,
&card,
)))
}
async fn create_rewards_card( async fn create_rewards_card(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
@@ -1140,6 +1181,108 @@ async fn remove_meal_from_list(
list_fragment_response(&state, &user, list_id).await list_fragment_response(&state, &user, list_id).await
} }
/// Marks a meal instance on a list as eaten (or not) without removing it.
async fn check_list_meal(
State(state): State<AppState>,
user: CurrentUser,
Path((list_id, list_meal_id)): Path<(i64, i64)>,
LoggedForm(form): LoggedForm<CheckForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_mutable_list(&state, list_id).await?;
let checked = match form.checked.as_str() {
"1" | "true" => true,
"0" | "false" => false,
_ => return Err(AppError::BadRequest("Invalid checked value.".into())),
};
state
.meals
.set_list_meal_checked(list_id, list_meal_id, checked)
.await?;
list_fragment_response(&state, &user, list_id).await
}
/// Renders the carry-over modal: a picker of active source lists (newest first,
/// excluding the current list) that the user can import meals from.
async fn carry_over_modal(
State(state): State<AppState>,
_user: CurrentUser,
Path(dest_list_id): Path<i64>,
) -> Result<Response, AppError> {
require_mutable_list(&state, dest_list_id).await?;
let lists = state.lists.list_summaries().await?;
let sources: Vec<_> = lists
.into_iter()
.filter(|list| list.id != dest_list_id)
.collect();
Ok(html_response(views::carry_over_modal(
&sources,
dest_list_id,
)))
}
/// Renders the selectable meal rows for a chosen source list inside the carry
/// modal.
async fn carry_source_meals(
State(state): State<AppState>,
user: CurrentUser,
Path(dest_list_id): Path<i64>,
Query(query): Query<CarrySourceQuery>,
) -> Result<Response, AppError> {
require_mutable_list(&state, dest_list_id).await?;
let source = require_list(&state, query.source).await?;
if source.id == dest_list_id {
return Err(AppError::BadRequest(
"Cannot carry meals from a list into itself.".into(),
));
}
let list_meals = state.meals.list_meals_on_list(source.id).await?;
Ok(html_response(views::carry_source_meals(
&list_meals,
source.id,
dest_list_id,
&user.session.csrf_token,
)))
}
/// Copies the selected meals from a source list into the current list without
/// re-adding their ingredients.
async fn carry_meals(
State(state): State<AppState>,
user: CurrentUser,
Path(dest_list_id): Path<i64>,
LoggedForm(form): LoggedForm<CarryMealsForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_mutable_list(&state, dest_list_id).await?;
let source = require_list(&state, form.source_list_id).await?;
if source.id == dest_list_id {
return Err(AppError::BadRequest(
"Cannot carry meals from a list into itself.".into(),
));
}
if form.list_meal_ids.is_empty() {
return Err(AppError::BadRequest(
"Select at least one meal to carry over.".into(),
));
}
let list_meal_ids: Vec<i64> = form
.list_meal_ids
.split(',')
.filter_map(|id| id.trim().parse().ok())
.collect();
if list_meal_ids.is_empty() {
return Err(AppError::BadRequest(
"Select at least one meal to carry over.".into(),
));
}
state
.meals
.carry_meals_to_list(source.id, dest_list_id, &list_meal_ids)
.await?;
list_fragment_response(&state, &user, dest_list_id).await
}
async fn create_invitation( async fn create_invitation(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
+2 -3
View File
@@ -135,7 +135,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// WebAuthn config from env vars. RP_ID must match the host users access the site from. // WebAuthn config from env vars. RP_ID must match the host users access the site from.
let rp_id = env::var("RP_ID").unwrap_or_else(|_| { let rp_id = env::var("RP_ID").unwrap_or_else(|_| {
let host = public_base_url public_base_url
.trim_start_matches("http://") .trim_start_matches("http://")
.trim_start_matches("https://") .trim_start_matches("https://")
.split('/') .split('/')
@@ -144,8 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.split(':') .split(':')
.next() .next()
.unwrap_or("localhost") .unwrap_or("localhost")
.to_owned(); .to_owned()
host
}); });
let rp_name = env::var("RP_NAME").unwrap_or_else(|_| "Sustenance".into()); let rp_name = env::var("RP_NAME").unwrap_or_else(|_| "Sustenance".into());
let origin = let origin =
+34 -1
View File
@@ -153,6 +153,10 @@ pub trait ItemRepository: Send + Sync {
item_id: i64, item_id: i64,
checked: bool, checked: bool,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
/// Update the mutable fields of an item. The bare field list mirrors the
/// edit form's inputs; clippy flags the argument count, which is justified
/// here by the flat domain signature.
#[allow(clippy::too_many_arguments)]
async fn update_item( async fn update_item(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
@@ -182,7 +186,7 @@ pub trait ListMealRepository: Send + Sync {
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64, list_id: i64,
meal_id: i64, meal_id: Option<i64>,
name: String, name: String,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
async fn remove_meal( async fn remove_meal(
@@ -191,6 +195,26 @@ pub trait ListMealRepository: Send + Sync {
list_id: i64, list_id: i64,
list_meal_id: i64, list_meal_id: i64,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
/// Marks a meal instance as eaten (or not) on a list, bumping the list's
/// revision exactly once. Like items, the meal stays in the list so it can
/// be toggled back.
async fn set_list_meal_checked(
&self,
txn: &mut SqliteConnection,
list_id: i64,
list_meal_id: i64,
checked: bool,
) -> DomainResult<i64>;
/// Copies the given meal instances from one list to another without
/// expanding their ingredients into items (they were already purchased).
/// Returns the new rows and the destination list's bumped revision.
async fn copy_meals_to_list(
&self,
txn: &mut SqliteConnection,
source_list_id: i64,
dest_list_id: i64,
list_meal_ids: &[i64],
) -> DomainResult<(Vec<ListMeal>, i64)>;
} }
#[async_trait] #[async_trait]
@@ -266,6 +290,9 @@ pub trait MealIngredientRepository: Send + Sync {
note: String, note: String,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
/// Update the mutable fields of a meal ingredient. Same flat signature
/// rationale as `ItemRepository::update_item`.
#[allow(clippy::too_many_arguments)]
async fn update_ingredient( async fn update_ingredient(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
@@ -323,6 +350,12 @@ pub trait RewardsCardRepository: Send + Sync {
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
user_id: i64, user_id: i64,
) -> DomainResult<Vec<RewardsCard>>; ) -> DomainResult<Vec<RewardsCard>>;
async fn get_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
card_id: i64,
) -> DomainResult<Option<RewardsCard>>;
async fn create_card( async fn create_card(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
+62 -1
View File
@@ -345,6 +345,9 @@ pub struct MealService {
} }
impl MealService { impl MealService {
/// Builds the meal service from its dependencies. The argument count is
/// intentional: it assembles the service's repository/notifier graph.
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
db: SqliteDatabase, db: SqliteDatabase,
meals: Arc<dyn MealRepository>, meals: Arc<dyn MealRepository>,
@@ -532,7 +535,7 @@ impl MealService {
return Err(DomainError::NotFound); return Err(DomainError::NotFound);
} }
let list_meal_id = list_meals let list_meal_id = list_meals
.add_meal(txn, list_id, meal.id, meal.name.clone()) .add_meal(txn, list_id, Some(meal.id), meal.name.clone())
.await?; .await?;
let new_items = meal let new_items = meal
.ingredients .ingredients
@@ -578,6 +581,57 @@ impl MealService {
self.realtime.publish_list_changed(list_id, revision).await; self.realtime.publish_list_changed(list_id, revision).await;
Ok(revision) Ok(revision)
} }
/// Marks a meal instance on a list as eaten (or not), keeping it in the list
/// and bumping the list revision exactly once.
pub async fn set_list_meal_checked(
&self,
list_id: i64,
list_meal_id: i64,
checked: bool,
) -> DomainResult<i64> {
let list_meals = Arc::clone(&self.list_meals);
let revision = self
.db
.run(move |txn| {
Box::pin(async move {
list_meals
.set_list_meal_checked(txn, list_id, list_meal_id, checked)
.await
})
})
.await?;
self.realtime.publish_list_changed(list_id, revision).await;
Ok(revision)
}
/// Copies the given meal instances from a source list into the destination
/// list without re-expanding their ingredients into items (they were already
/// purchased). The source list is left untouched. Publishes a realtime update
/// for the destination only.
pub async fn carry_meals_to_list(
&self,
source_list_id: i64,
dest_list_id: i64,
list_meal_ids: &[i64],
) -> DomainResult<Vec<ListMeal>> {
let list_meals = Arc::clone(&self.list_meals);
let ids = list_meal_ids.to_vec();
let (copied, revision) = self
.db
.run(move |txn| {
Box::pin(async move {
list_meals
.copy_meals_to_list(txn, source_list_id, dest_list_id, &ids)
.await
})
})
.await?;
self.realtime
.publish_list_changed(dest_list_id, revision)
.await;
Ok(copied)
}
} }
pub struct InvitationService { pub struct InvitationService {
@@ -648,6 +702,13 @@ impl RewardsCardService {
.await .await
} }
pub async fn get_card(&self, user_id: i64, card_id: i64) -> DomainResult<Option<RewardsCard>> {
let cards = Arc::clone(&self.cards);
self.db
.run(move |txn| Box::pin(async move { cards.get_card(txn, user_id, card_id).await }))
.await
}
pub async fn create_card( pub async fn create_card(
&self, &self,
user_id: i64, user_id: i64,
+423 -121
View File
File diff suppressed because it is too large Load Diff
+244 -113
View File
@@ -802,116 +802,6 @@ fn render_markdown(source: &str) -> Markup {
} }
} }
#[cfg(test)]
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();
assert!(html.contains("<ul>"), "expected <ul>, got: {html}");
assert!(html.contains("<li>"), "expected <li>, got: {html}");
assert!(!html.contains("* one"), "raw bullet leaked through: {html}");
}
#[test]
fn render_markdown_renders_emphasis() {
let html = render_markdown("**bold** and *italic*").into_string();
assert!(html.contains("<strong>"), "expected <strong>, got: {html}");
assert!(html.contains("<em>"), "expected <em>, got: {html}");
}
#[test]
fn format_date_renders_human_readable_date() {
// 2026-08-07T00:00:00Z in Unix seconds.
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( pub fn list_page(
user: &User, user: &User,
list: &GroceryList, list: &GroceryList,
@@ -1288,7 +1178,26 @@ pub fn list_meals_panel(
} @else { } @else {
div class="list-meals" { div class="list-meals" {
@for meal in list_meals { @for meal in list_meals {
div class="list-meal-row" { div
class=(if meal.checked { "list-meal-row is-checked" } else { "list-meal-row" })
id=(format!("list-meal-{}", meal.id))
{
@if editable {
form
class="list-meal-check"
hx-post=(format!("/lists/{}/meals/{}/check", list_id, meal.id))
hx-target="#list-items"
hx-swap="morph:outerHTML"
{
input type="hidden" name="csrf" value=(csrf_token);
input type="hidden" name="checked" value=(if meal.checked { "0" } else { "1" });
button type="submit" class="check-button list-meal-check-button" aria-label=(if meal.checked { format!("Mark {} as not eaten", meal.name) } else { format!("Mark {} as eaten", meal.name) }) {
@if meal.checked { "" } @else { "" }
}
}
} @else if meal.checked {
span class="check-button check-button-static list-meal-check-button" { "" }
}
span class="list-meal-icon" { "🍽" } span class="list-meal-icon" { "🍽" }
span class="list-meal-name" { (meal.name) } span class="list-meal-name" { (meal.name) }
@if editable { @if editable {
@@ -1307,7 +1216,10 @@ pub fn list_meals_panel(
} }
} }
@if editable { @if editable {
div class="list-meals-actions" {
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" } button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
button class="button button-small carry-over-button" hx-get=(format!("/lists/{}/carry", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "Carry over" }
}
} }
}; };
@@ -1322,6 +1234,91 @@ pub fn list_meals_panel(
} }
} }
/// The carry-over modal: a picker of active source lists (newest first,
/// excluding the current list) that the user can import meals from.
pub fn carry_over_modal(sources: &[GroceryList], dest_list_id: i64) -> Markup {
html! {
div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" {
div class="meal-picker-modal" role="dialog" aria-modal="true" aria-label="Carry over meals" {
div class="meal-picker-header" {
div {
p class="eyebrow" { "CARRY OVER" }
h2 { "Carry over meals" }
}
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "" }
}
p class="muted carry-intro" { "Import meals from another list. Their ingredients are not re-added — they were already purchased." }
@if sources.is_empty() {
div class="meal-picker-empty" {
p { "No other lists to carry from." }
}
} @else {
div class="carry-source-picker" {
label for="carry-source" { "From list" }
select
id="carry-source"
name="source"
hx-get=(format!("/lists/{}/carry/meals", dest_list_id))
hx-trigger="change"
hx-target="#carry-results"
hx-swap="innerHTML"
{
option value="" selected disabled { "Choose a list…" }
@for source in sources {
option value=(source.id) { (source.name) }
}
}
}
div id="carry-results" class="carry-results" {}
}
}
}
}
}
/// The selectable meal rows for a chosen source list inside the carry modal.
pub fn carry_source_meals(
list_meals: &[ListMeal],
source_id: i64,
dest_list_id: i64,
csrf_token: &str,
) -> Markup {
html! {
@if list_meals.is_empty() {
div class="meal-picker-empty" {
p { "This list has no meals to carry over." }
}
} @else {
form
hx-post=(format!("/lists/{}/carry", dest_list_id))
hx-target="#list-items"
hx-swap="morph:outerHTML"
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
class="carry-form"
{
input type="hidden" name="csrf" value=(csrf_token);
input type="hidden" name="source_list_id" value=(source_id);
// Collected as a comma-separated list by the checkboxes below;
// the form extractor does not coalesce repeated keys into a Vec.
input id="carry-selected" type="hidden" name="list_meal_ids" value="";
div class="carry-list" {
@for meal in list_meals {
label class="carry-row" {
input
type="checkbox"
value=(meal.id)
onchange="var form=this.closest('form'); var box=form.querySelector('#carry-selected'); box.value=Array.from(form.querySelectorAll('input[type=checkbox]:checked')).map(function(c){return c.value}).join(',');";
span class="carry-row-icon" { "🍽" }
span class="carry-row-name" { (meal.name) }
}
}
}
button class="button button-primary carry-submit" type="submit" { "Carry over" }
}
}
}
}
pub fn presence_panel(presence: &[PresenceUser], out_of_band: bool) -> Markup { pub fn presence_panel(presence: &[PresenceUser], out_of_band: bool) -> Markup {
if out_of_band { if out_of_band {
html! { html! {
@@ -1434,6 +1431,7 @@ pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Mar
div class="rewards-grid" { div class="rewards-grid" {
@for card in cards { @for card in cards {
div class="rewards-card" { div class="rewards-card" {
a class="rewards-card-link" href=(format!("/rewards/{}", card.id)) aria-label=(format!("Show {} barcode", card.store_name)) {}
div class="rewards-card-heading" { div class="rewards-card-heading" {
strong { (card.store_name) } strong { (card.store_name) }
form method="post" action=(format!("/rewards/{}/delete", card.id)) hx-confirm="Remove this rewards card? This cannot be undone." { form method="post" action=(format!("/rewards/{}/delete", card.id)) hx-confirm="Remove this rewards card? This cannot be undone." {
@@ -1471,6 +1469,29 @@ pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Mar
) )
} }
/// A focused, single-barcode view for scanning at the register. Only one
/// barcode is shown at a time so a scanner can't pick up multiple codes.
pub fn rewards_scan_page(user: &User, card: &RewardsCard) -> Markup {
page(
&card.store_name,
Some(user),
html! {
div class="scan-page" {
a class="button button-quiet scan-back" href="/rewards" { "← All cards" }
div class="scan-card" {
p class="eyebrow" { "REWARDS CARD" }
h1 class="scan-store" { (card.store_name) }
div class="scan-barcode" {
(barcode_svg(&card.symbology, &card.number))
}
p class="rewards-number" { (card.number) }
}
}
script src=(crate::assets::url("rewards.js")) {}
},
)
}
/// Renders a rewards-card number as an inline SVG barcode using the given /// 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 /// 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). /// (for example, a Code 128 number that isn't valid for the chosen symbology).
@@ -1484,7 +1505,7 @@ fn barcode_svg(symbology: &str, number: &str) -> Markup {
.generate(&encoded) .generate(&encoded)
.ok() .ok()
}), }),
_ => barcoders::sym::code128::Code128::new(&code128_input(number)) _ => barcoders::sym::code128::Code128::new(code128_input(number))
.ok() .ok()
.map(|code| code.encode()) .map(|code| code.encode())
.and_then(|encoded| { .and_then(|encoded| {
@@ -1516,7 +1537,7 @@ fn code128_input(number: &str) -> String {
return format!("Ɓ{number}"); return format!("Ɓ{number}");
} }
let len = number.chars().count(); let len = number.chars().count();
if len % 2 == 0 { if len.is_multiple_of(2) {
format!("Ć{number}") format!("Ć{number}")
} else { } else {
let (first, rest) = number.split_at(1); let (first, rest) = number.split_at(1);
@@ -1611,3 +1632,113 @@ fn initials(name: &str) -> String {
.collect::<String>() .collect::<String>()
.to_uppercase() .to_uppercase()
} }
#[cfg(test)]
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();
assert!(html.contains("<ul>"), "expected <ul>, got: {html}");
assert!(html.contains("<li>"), "expected <li>, got: {html}");
assert!(!html.contains("* one"), "raw bullet leaked through: {html}");
}
#[test]
fn render_markdown_renders_emphasis() {
let html = render_markdown("**bold** and *italic*").into_string();
assert!(html.contains("<strong>"), "expected <strong>, got: {html}");
assert!(html.contains("<em>"), "expected <em>, got: {html}");
}
#[test]
fn format_date_renders_human_readable_date() {
// 2026-08-07T00:00:00Z in Unix seconds.
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}");
}
}
+30
View File
@@ -0,0 +1,30 @@
// Keep the screen awake while a rewards barcode is on screen so the cashier
// can scan it without the display dimming or locking. This mirrors how wallet
// apps behave when showing a barcode. The Screen Wake Lock API is best-effort:
// it may be rejected (e.g. low battery) or auto-released when the tab is hidden,
// so we re-acquire whenever the page becomes visible again.
(function () {
var wakeLock = null;
function requestWakeLock() {
if (!("wakeLock" in navigator)) return;
navigator.wakeLock
.request("screen")
.then(function (sentinel) {
wakeLock = sentinel;
})
.catch(function () {
// Best-effort only; ignore failures (unsupported, low battery, etc.).
});
}
// Re-acquire if the lock was released (e.g. the tab was hidden) and the user
// returns to the page.
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible" && wakeLock === null) {
requestWakeLock();
}
});
requestWakeLock();
})();
+69
View File
@@ -259,12 +259,33 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
.list-meals { display: grid; gap: 8px; } .list-meals { display: grid; gap: 8px; }
.list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; } .list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; }
.list-meal-row:last-child { border-bottom: 0; } .list-meal-row:last-child { border-bottom: 0; }
.list-meal-check { margin: 0; flex: 0 0 auto; }
.list-meal-check .check-button { width: 26px; height: 26px; }
.is-checked .list-meal-name { color: var(--muted); text-decoration: line-through; }
.list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; } .list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
.is-checked .list-meal-icon { filter: grayscale(.4); opacity: .7; }
.list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; } .list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
.list-meal-remove { margin: 0; flex: 0 0 auto; } .list-meal-remove { margin: 0; flex: 0 0 auto; }
.list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; } .list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; }
.list-meal-remove-button:hover { color: var(--coral); background: #fbeae4; } .list-meal-remove-button:hover { color: var(--coral); background: #fbeae4; }
.list-meals-panel .add-meal-button { margin-top: 12px; width: 100%; } .list-meals-panel .add-meal-button { margin-top: 12px; width: 100%; }
.list-meals-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
.list-meals-actions .button { width: 100%; margin: 0; }
.carry-over-button { color: var(--deep-sage); background: #eef4e9; }
.carry-over-button:hover { background: #e3eddc; }
.carry-intro { margin: 14px 24px 4px; font-size: .85rem; }
.carry-source-picker { padding: 14px 24px 4px; }
.carry-source-picker label { display: block; margin-bottom: 6px; font-size: .78rem; font-weight: 800; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.carry-source-picker select { width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 12px; color: var(--ink); background: #fff; font-size: .9rem; }
.carry-results { padding: 14px 24px 22px; overflow-y: auto; }
.carry-form { margin: 0; }
.carry-list { display: grid; gap: 8px; }
.carry-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 14px; background: #fff; cursor: pointer; }
.carry-row:hover { border-color: var(--sage); }
.carry-row input { accent-color: var(--deep-sage); width: 17px; height: 17px; }
.carry-row-icon { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 11px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
.carry-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
.carry-submit { margin-top: 14px; width: 100%; }
.sharing-panel p, .tip-panel p { color: var(--muted); font-size: .86rem; } .sharing-panel p, .tip-panel p { color: var(--muted); font-size: .86rem; }
.invite-result { margin-top: 15px; } .invite-result { margin-top: 15px; }
.invite-link-result { padding: 11px; border-radius: 12px; background: #f1f5ec; } .invite-link-result { padding: 11px; border-radius: 12px; background: #f1f5ec; }
@@ -412,10 +433,20 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
/* Rewards cards */ /* Rewards cards */
.rewards-grid { display: grid; gap: 16px; } .rewards-grid { display: grid; gap: 16px; }
.rewards-card { .rewards-card {
position: relative;
padding: 18px; padding: 18px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 18px;
background: #fff; background: #fff;
transition: border-color .16s ease, transform .16s ease;
}
.rewards-card:hover { border-color: var(--sage); transform: translateY(-1px); }
/* Stretched link: makes the whole card clickable to open the scan view. */
.rewards-card-link {
position: absolute;
inset: 0;
border-radius: inherit;
z-index: 1;
} }
.rewards-card-heading { .rewards-card-heading {
display: flex; display: flex;
@@ -445,6 +476,44 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
color: var(--coral); color: var(--coral);
font-weight: 700; font-weight: 700;
} }
/* Keep the Remove button above the stretched link so it stays clickable. */
.rewards-card-heading form { position: relative; z-index: 2; }
/* Single-card scan view */
.scan-page {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
padding: 12px 0 40px;
}
.scan-back { align-self: flex-start; }
.scan-card {
width: min(100%, 460px);
padding: clamp(24px, 6vw, 42px);
border: 1px solid var(--line);
border-radius: 28px;
background: #fff;
box-shadow: var(--shadow);
text-align: center;
}
.scan-store { margin: 4px 0 26px; font-size: clamp(1.3rem, 4vw, 1.7rem); }
.scan-barcode {
display: flex;
justify-content: center;
padding: 22px;
border-radius: 14px;
background: #fff;
}
.scan-barcode svg {
width: 100%;
height: auto;
max-width: 420px;
}
.scan-card .rewards-number {
margin-top: 18px;
font-size: 1rem;
}
select { select {
width: 100%; width: 100%;
min-height: 46px; min-height: 46px;