From b80dda065a8f610cbc7dd21885279e423acfb841 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 26 Sep 2026 11:25:56 -0500 Subject: [PATCH 1/4] feat: implement useMultiSelect hook and integrate into NftList and Offers pages - Added useMultiSelect hook for managing multi-selection state. - Created tests for useMultiSelect to ensure correct functionality. - Refactored NftList component to utilize useMultiSelect for NFT selection. - Updated Offers component to incorporate multi-select actions and state management. - Introduced useOfferData and useOfferParams hooks for better offer data handling. - Added tests for useOfferData to validate API interactions and state management. - Implemented fetchOfferRecords function to handle offer fetching with error management. - Enhanced Offers page with pagination and improved user experience for offer management. --- Cargo.lock | 1 + crates/sage-api/src/requests/offers.rs | 51 ++- crates/sage-database/Cargo.toml | 4 + crates/sage-database/src/tables/offers.rs | 388 +++++++++++++++++- crates/sage/src/endpoints/offers.rs | 94 ++++- src/bindings.ts | 44 +- src/components/DebouncedSearchInput.test.tsx | 120 ++++++ src/components/DebouncedSearchInput.tsx | 93 +++++ src/components/MultiSelectToggle.tsx | 40 ++ src/components/NftOptions.tsx | 89 +--- src/components/OfferOptions.tsx | 251 +++++++++++ .../OffersMultiSelectActions.test.tsx | 187 +++++++++ src/components/OffersMultiSelectActions.tsx | 53 ++- src/hooks/useMultiSelect.test.tsx | 61 +++ src/hooks/useMultiSelect.ts | 49 +++ src/hooks/useOfferData.test.tsx | 197 +++++++++ src/hooks/useOfferData.ts | 110 +++++ src/hooks/useOfferParams.ts | 91 ++++ src/lib/offers.test.ts | 71 ++++ src/lib/offers.ts | 32 +- src/pages/NftList.tsx | 33 +- src/pages/Offers.tsx | 377 +++++++---------- 22 files changed, 2093 insertions(+), 343 deletions(-) create mode 100644 src/components/DebouncedSearchInput.test.tsx create mode 100644 src/components/DebouncedSearchInput.tsx create mode 100644 src/components/MultiSelectToggle.tsx create mode 100644 src/components/OfferOptions.tsx create mode 100644 src/components/OffersMultiSelectActions.test.tsx create mode 100644 src/hooks/useMultiSelect.test.tsx create mode 100644 src/hooks/useMultiSelect.ts create mode 100644 src/hooks/useOfferData.test.tsx create mode 100644 src/hooks/useOfferData.ts create mode 100644 src/hooks/useOfferParams.ts create mode 100644 src/lib/offers.test.ts diff --git a/Cargo.lock b/Cargo.lock index 6d1ea4118..a85febe34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6647,6 +6647,7 @@ dependencies = [ "hex", "sqlx", "thiserror 1.0.69", + "tokio", "tracing", ] diff --git a/crates/sage-api/src/requests/offers.rs b/crates/sage-api/src/requests/offers.rs index bfdf89886..4dfa6cfec 100644 --- a/crates/sage-api/src/requests/offers.rs +++ b/crates/sage-api/src/requests/offers.rs @@ -196,10 +196,55 @@ pub struct ImportOfferResponse { description = "List all offers created by or available to this wallet." ) )] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetOffers {} +pub struct GetOffers { + /// Starting offset for pagination + #[serde(default)] + pub offset: Option, + /// Maximum number of offers to return (all when omitted) + #[serde(default)] + pub limit: Option, + /// Only return offers with this status + #[serde(default)] + pub status: Option, + /// Search by asset name, ticker, asset/offer id, or NFT/DID/option address + #[serde(default)] + pub find_value: Option, + /// Which side of the offer the search applies to (defaults to any) + #[serde(default)] + pub find_side: Option, + /// Sort column (defaults to creation time) + #[serde(default)] + pub sort_mode: Option, + /// Sort ascending (defaults to false, newest first) + #[serde(default)] + pub ascending: bool, +} + +/// Which side of an offer a search matches +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum OfferFindSide { + #[default] + Any, + Offered, + Requested, +} + +/// Offer list sort column +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum OfferSortMode { + #[default] + Created, + Expiration, +} /// Response with list of offers #[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Offers"))] @@ -209,6 +254,8 @@ pub struct GetOffers {} pub struct GetOffersResponse { /// List of offers pub offers: Vec, + /// Total number of offers matching the filters, before paging + pub total: u32, } /// Get offers for a specific asset diff --git a/crates/sage-database/Cargo.toml b/crates/sage-database/Cargo.toml index 36ae741f3..26cd60e15 100644 --- a/crates/sage-database/Cargo.toml +++ b/crates/sage-database/Cargo.toml @@ -20,3 +20,7 @@ sqlx = { workspace = true, features = ["sqlite"] } thiserror = { workspace = true } tracing = { workspace = true } hex = { workspace = true } + +[dev-dependencies] +sqlx = { workspace = true, features = ["sqlite", "runtime-tokio", "migrate"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/sage-database/src/tables/offers.rs b/crates/sage-database/src/tables/offers.rs index 0cb040b83..ba694f1c3 100644 --- a/crates/sage-database/src/tables/offers.rs +++ b/crates/sage-database/src/tables/offers.rs @@ -1,6 +1,6 @@ use crate::{Asset, Convert, Database, DatabaseTx, Result}; use chia_wallet_sdk::prelude::*; -use sqlx::SqliteExecutor; +use sqlx::{QueryBuilder, Row, Sqlite, SqliteExecutor}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[repr(u8)] @@ -32,6 +32,33 @@ pub struct OfferedAsset { pub royalty: u64, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OfferSearchSide { + #[default] + Any, + Offered, + Requested, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OfferSortColumn { + #[default] + Created, + Expiration, +} + +#[derive(Debug, Clone, Default)] +pub struct OffersPageParams { + pub status: Option, + pub find_text: Option, + pub find_id: Option, + pub side: OfferSearchSide, + pub sort: OfferSortColumn, + pub ascending: bool, + pub limit: Option, + pub offset: u32, +} + impl Database { pub async fn offer(&self, offer_id: Bytes32) -> Result> { offer(&self.pool, offer_id).await @@ -49,6 +76,10 @@ impl Database { offers(&self.pool, status).await } + pub async fn offers_page(&self, params: OffersPageParams) -> Result<(Vec, u32)> { + offers_page(&self.pool, params).await + } + pub async fn update_offer_status(&self, offer_id: Bytes32, status: OfferStatus) -> Result<()> { update_offer_status(&self.pool, offer_id, status).await } @@ -376,6 +407,145 @@ async fn offers( .collect() } +fn escape_like(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +fn offer_status(value: i64) -> Result { + Ok(match value { + 0 => OfferStatus::Pending, + 1 => OfferStatus::Active, + 2 => OfferStatus::Completed, + 3 => OfferStatus::Cancelled, + 4 => OfferStatus::Expired, + _ => return Err(crate::DatabaseError::InvalidEnumVariant), + }) +} + +async fn offers_page( + conn: impl SqliteExecutor<'_>, + params: OffersPageParams, +) -> Result<(Vec, u32)> { + let mut query = QueryBuilder::::new( + "SELECT + hash AS offer_id, encoded_offer, fee, status, expiration_height, + expiration_timestamp, inserted_timestamp, COUNT(*) OVER() AS total_count + FROM offers + WHERE 1=1", + ); + + if let Some(status) = params.status { + query.push(" AND status = "); + query.push_bind(status as u8); + } + + let find_text = params + .find_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if find_text.is_some() || params.find_id.is_some() { + query.push(" AND ("); + + if let Some(find_id) = params.find_id { + query.push("offers.hash = "); + query.push_bind(find_id.to_vec()); + query.push(" OR "); + } + + // Driving from offer_assets lets exact matches use the unique hash indexes. + query.push( + "offers.id IN ( + SELECT oa.offer_id FROM offer_assets oa + INNER JOIN assets a ON a.id = oa.asset_id + WHERE ", + ); + + match params.side { + OfferSearchSide::Any => {} + OfferSearchSide::Offered => { + query.push("oa.is_requested = 0 AND "); + } + OfferSearchSide::Requested => { + query.push("oa.is_requested = 1 AND "); + } + } + + query.push("(0"); + + if let Some(find_text) = find_text { + let pattern = format!("%{}%", escape_like(find_text)); + query.push(" OR a.name LIKE "); + query.push_bind(pattern.clone()); + query.push(" ESCAPE '\\' OR a.ticker LIKE "); + query.push_bind(pattern); + query.push(" ESCAPE '\\'"); + } + + if let Some(find_id) = params.find_id { + query.push(" OR a.hash = "); + query.push_bind(find_id.to_vec()); + } + + query.push(")))"); + } + + let direction = if params.ascending { "ASC" } else { "DESC" }; + + match params.sort { + OfferSortColumn::Created => { + query.push(format!( + " ORDER BY inserted_timestamp {direction}, offers.id {direction}" + )); + } + OfferSortColumn::Expiration => { + query.push(format!( + " ORDER BY expiration_timestamp IS NULL, expiration_timestamp {direction}, + inserted_timestamp DESC, offers.id DESC" + )); + } + } + + if params.limit.is_some() || params.offset > 0 { + // SQLite requires LIMIT before OFFSET; -1 means unlimited. + query.push(" LIMIT "); + query.push_bind(params.limit.map_or(-1, i64::from)); + query.push(" OFFSET "); + query.push_bind(params.offset); + } + + let rows = query.build().fetch_all(conn).await?; + + let total = rows + .first() + .map_or(Ok(0), |row| row.get::("total_count").try_into())?; + + let offers = rows + .into_iter() + .map(|row| { + Ok(OfferRow { + offer_id: row.get::, _>("offer_id").convert()?, + encoded_offer: row.get("encoded_offer"), + expiration_height: row + .get::, _>("expiration_height") + .map(|h| h as u32), + expiration_timestamp: row + .get::, _>("expiration_timestamp") + .map(|t| t as u64), + fee: row.get::, _>("fee").convert()?, + status: offer_status(row.get("status"))?, + inserted_timestamp: row.get::("inserted_timestamp") as u64, + }) + }) + .collect::>>()?; + + Ok((offers, total)) +} + async fn delete_offer(conn: impl SqliteExecutor<'_>, offer_id: Bytes32) -> Result<()> { let offer_id_ref = offer_id.as_ref(); sqlx::query("DELETE FROM offers WHERE hash = ?") @@ -398,3 +568,219 @@ async fn update_offer_status( .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + const SPACE: Bytes32 = Bytes32::new([0xa1; 32]); + const MARMOT: Bytes32 = Bytes32::new([0xb2; 32]); + const PURE: Bytes32 = Bytes32::new([0xc3; 32]); + + async fn setup() -> Database { + // One connection so every query sees the same in-memory database. + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("../../migrations").run(&pool).await.unwrap(); + let db = Database::new(pool); + + for (hash, name, ticker) in [ + (SPACE, "Spacebucks", "SBX"), + (MARMOT, "Marmot Coin", "MRMT"), + (PURE, "100%_Pure", "PURE"), + ] { + sqlx::query( + "INSERT INTO assets (hash, kind, name, ticker, precision, is_visible) + VALUES (?, 0, ?, ?, 3, 1)", + ) + .bind(hash.to_vec()) + .bind(name) + .bind(ticker) + .execute(&db.pool) + .await + .unwrap(); + } + + // id, status, inserted, expiration, offered, requested + for (id, status, inserted, expiration, offered, requested) in [ + (1, OfferStatus::Active, 100, Some(5000), SPACE, MARMOT), + (2, OfferStatus::Completed, 200, None, MARMOT, PURE), + (3, OfferStatus::Active, 300, Some(4000), PURE, SPACE), + (4, OfferStatus::Cancelled, 300, Some(6000), MARMOT, SPACE), + ] { + insert_test_offer(&db, id, status, inserted, expiration, offered, requested).await; + } + + db + } + + async fn insert_test_offer( + db: &Database, + id: u8, + status: OfferStatus, + inserted: u64, + expiration: Option, + offered: Bytes32, + requested: Bytes32, + ) { + let offer_id = Bytes32::new([id; 32]); + let mut tx = db.tx().await.unwrap(); + tx.insert_offer(OfferRow { + offer_id, + encoded_offer: format!("offer{id}"), + expiration_height: None, + expiration_timestamp: expiration, + fee: 0, + status, + inserted_timestamp: inserted, + }) + .await + .unwrap(); + tx.insert_offer_asset(offer_id, offered, 1, 0, false).await.unwrap(); + tx.insert_offer_asset(offer_id, requested, 1, 0, true).await.unwrap(); + tx.commit().await.unwrap(); + } + + async fn ids(db: &Database, params: OffersPageParams) -> (Vec, u32) { + let (rows, total) = db.offers_page(params).await.unwrap(); + (rows.iter().map(|row| row.offer_id[0]).collect(), total) + } + + fn text(value: &str) -> OffersPageParams { + OffersPageParams { + find_text: Some(value.to_string()), + ..Default::default() + } + } + + #[tokio::test] + async fn defaults_return_all_newest_first() { + let db = setup().await; + // 4 and 3 share inserted_timestamp 300; row id breaks the tie. + assert_eq!(ids(&db, OffersPageParams::default()).await, (vec![4, 3, 2, 1], 4)); + } + + #[tokio::test] + async fn filters_by_status() { + let db = setup().await; + let params = OffersPageParams { + status: Some(OfferStatus::Active), + ..Default::default() + }; + assert_eq!(ids(&db, params).await, (vec![3, 1], 2)); + } + + #[tokio::test] + async fn searches_name_and_ticker_case_insensitively() { + let db = setup().await; + // SPACE is offered in 1 and requested in 3 and 4. + assert_eq!(ids(&db, text("space")).await, (vec![4, 3, 1], 3)); + assert_eq!(ids(&db, text("mrmt")).await, (vec![4, 2, 1], 3)); + assert_eq!(ids(&db, text(" space ")).await, (vec![4, 3, 1], 3)); + assert_eq!(ids(&db, text(" ")).await, (vec![4, 3, 2, 1], 4)); + } + + #[tokio::test] + async fn restricts_search_to_side() { + let db = setup().await; + let offered = OffersPageParams { + side: OfferSearchSide::Offered, + ..text("space") + }; + let requested = OffersPageParams { + side: OfferSearchSide::Requested, + ..text("space") + }; + assert_eq!(ids(&db, offered).await, (vec![1], 1)); + assert_eq!(ids(&db, requested).await, (vec![4, 3], 2)); + } + + #[tokio::test] + async fn matches_exact_asset_and_offer_ids() { + let db = setup().await; + let asset = OffersPageParams { + find_text: Some(hex::encode(SPACE)), + find_id: Some(SPACE), + ..Default::default() + }; + let offer = OffersPageParams { + find_text: Some(hex::encode([2u8; 32])), + find_id: Some(Bytes32::new([2; 32])), + ..Default::default() + }; + assert_eq!(ids(&db, asset).await, (vec![4, 3, 1], 3)); + assert_eq!(ids(&db, offer).await, (vec![2], 1)); + } + + #[tokio::test] + async fn escapes_like_wildcards() { + let db = setup().await; + // Only "100%_Pure" contains a literal % or _. + assert_eq!(ids(&db, text("%")).await, (vec![3, 2], 2)); + assert_eq!(ids(&db, text("_")).await, (vec![3, 2], 2)); + } + + #[tokio::test] + async fn sorts_by_expiration_with_non_expiring_last() { + let db = setup().await; + let asc = OffersPageParams { + sort: OfferSortColumn::Expiration, + ascending: true, + ..Default::default() + }; + let desc = OffersPageParams { + sort: OfferSortColumn::Expiration, + ascending: false, + ..Default::default() + }; + assert_eq!(ids(&db, asc).await, (vec![3, 1, 4, 2], 4)); + assert_eq!(ids(&db, desc).await, (vec![4, 1, 3, 2], 4)); + } + + #[tokio::test] + async fn pages_with_total() { + let db = setup().await; + let first = OffersPageParams { + limit: Some(2), + ..Default::default() + }; + let second = OffersPageParams { + limit: Some(2), + offset: 2, + ..Default::default() + }; + let offset_only = OffersPageParams { + offset: 3, + ..Default::default() + }; + assert_eq!(ids(&db, first).await, (vec![4, 3], 4)); + assert_eq!(ids(&db, second).await, (vec![2, 1], 4)); + assert_eq!(ids(&db, offset_only).await, (vec![1], 4)); + } + + #[tokio::test] + async fn paging_is_stable_with_equal_timestamps() { + let db = setup().await; + for id in 10..20 { + insert_test_offer(&db, id, OfferStatus::Active, 999, None, SPACE, MARMOT).await; + } + let mut seen = Vec::new(); + for page in 0..5 { + let params = OffersPageParams { + limit: Some(3), + offset: page * 3, + ..Default::default() + }; + seen.extend(ids(&db, params).await.0); + } + let mut unique = seen.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(seen.len(), 14); + assert_eq!(unique.len(), 14); + } +} diff --git a/crates/sage/src/endpoints/offers.rs b/crates/sage/src/endpoints/offers.rs index 20fc81284..7bd3469d5 100644 --- a/crates/sage/src/endpoints/offers.rs +++ b/crates/sage/src/endpoints/offers.rs @@ -9,11 +9,14 @@ use sage_api::{ CombineOffersResponse, DeleteOffer, DeleteOfferResponse, GetOffer, GetOfferResponse, GetOffers, GetOffersForAsset, GetOffersForAssetResponse, GetOffersResponse, ImportOffer, ImportOfferResponse, MakeOffer, MakeOfferResponse, NftRoyalty, OfferAmount, OfferAsset, - OfferRecord, OfferRecordStatus, OfferSummary, OptionAssets, TakeOffer, TakeOfferResponse, - ViewOffer, ViewOfferResponse, + OfferFindSide, OfferRecord, OfferRecordStatus, OfferSortMode, OfferSummary, OptionAssets, + TakeOffer, TakeOfferResponse, ViewOffer, ViewOfferResponse, }; use sage_assets::fetch_uris_with_hash; -use sage_database::{AssetKind, OfferRow, OfferStatus, OfferedAsset}; +use sage_database::{ + AssetKind, OfferRow, OfferSearchSide, OfferSortColumn, OfferStatus, OfferedAsset, + OffersPageParams, +}; use sage_wallet::{ Offered, Requested, RequestedCat, SyncCommand, TakenOffer, Transaction, Wallet, WalletError, aggregate_offers, insert_transaction, sort_offer, @@ -24,8 +27,8 @@ use tracing::debug; use crate::{ ConfirmationInfo, Error, ExtractedNftData, Result, Sage, extract_nft_data, json_bundle, - offer_expiration, parse_amount, parse_asset_id, parse_coin_ids, parse_hash, parse_nft_id, - parse_offer_id, parse_option_id, + offer_expiration, parse_amount, parse_asset_id, parse_coin_ids, parse_did_id, parse_hash, + parse_nft_id, parse_offer_id, parse_option_id, }; #[derive(Debug, Clone)] @@ -555,9 +558,43 @@ impl Sage { }) } - pub async fn get_offers(&self, _req: GetOffers) -> Result { + pub async fn get_offers(&self, req: GetOffers) -> Result { let wallet = self.wallet()?; - let offers = wallet.db.offers(None).await?; + + let find_text = req + .find_value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let find_id = find_text.clone().and_then(parse_find_id); + + let (offers, total) = wallet + .db + .offers_page(OffersPageParams { + status: req.status.map(|status| match status { + OfferRecordStatus::Pending => OfferStatus::Pending, + OfferRecordStatus::Active => OfferStatus::Active, + OfferRecordStatus::Completed => OfferStatus::Completed, + OfferRecordStatus::Cancelled => OfferStatus::Cancelled, + OfferRecordStatus::Expired => OfferStatus::Expired, + }), + find_text, + find_id, + side: match req.find_side.unwrap_or_default() { + OfferFindSide::Any => OfferSearchSide::Any, + OfferFindSide::Offered => OfferSearchSide::Offered, + OfferFindSide::Requested => OfferSearchSide::Requested, + }, + sort: match req.sort_mode.unwrap_or_default() { + OfferSortMode::Created => OfferSortColumn::Created, + OfferSortMode::Expiration => OfferSortColumn::Expiration, + }, + ascending: req.ascending, + limit: req.limit, + offset: req.offset.unwrap_or(0), + }) + .await?; let mut records = Vec::new(); @@ -565,7 +602,10 @@ impl Sage { records.push(self.offer_record(&wallet, offer).await?); } - Ok(GetOffersResponse { offers: records }) + Ok(GetOffersResponse { + offers: records, + total, + }) } pub async fn get_offers_for_asset( @@ -743,3 +783,41 @@ impl Sage { self.transact(coin_spends, req.auto_submit).await } } + +/// Interprets a search string as an exact id: 64-char hex (offer or asset id, +/// optional 0x prefix) or an nft / did:chia / option address. +fn parse_find_id(value: String) -> Option { + let hex = value.strip_prefix("0x").unwrap_or(&value).to_string(); + + parse_offer_id(hex) + .ok() + .or_else(|| parse_nft_id(value.clone()).ok()) + .or_else(|| parse_did_id(value.clone()).ok()) + .or_else(|| parse_option_id(value).ok()) +} + +#[cfg(test)] +mod tests { + use super::parse_find_id; + use chia_wallet_sdk::prelude::*; + + #[test] + fn parses_hex_ids_with_or_without_prefix() { + let id = Bytes32::new([7; 32]); + assert_eq!(parse_find_id(hex::encode(id)), Some(id)); + assert_eq!(parse_find_id(format!("0x{}", hex::encode(id))), Some(id)); + } + + #[test] + fn parses_nft_addresses() { + let id = Bytes32::new([9; 32]); + let nft = Address::new(id, "nft".to_string()).encode().unwrap(); + assert_eq!(parse_find_id(nft), Some(id)); + } + + #[test] + fn ignores_plain_text() { + assert_eq!(parse_find_id("spacebucks".to_string()), None); + assert_eq!(parse_find_id("abcd".to_string()), None); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index ebce25038..1b909ecb2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1397,7 +1397,35 @@ offer: OfferRecord } /** * List all offers */ -export type GetOffers = Record +export type GetOffers = { +/** + * Starting offset for pagination + */ +offset?: number | null; +/** + * Maximum number of offers to return (all when omitted) + */ +limit?: number | null; +/** + * Only return offers with this status + */ +status?: OfferRecordStatus | null; +/** + * Search by asset name, ticker, asset/offer id, or NFT/DID/option address + */ +find_value?: string | null; +/** + * Which side of the offer the search applies to (defaults to any) + */ +find_side?: OfferFindSide | null; +/** + * Sort column (defaults to creation time) + */ +sort_mode?: OfferSortMode | null; +/** + * Sort ascending (defaults to false, newest first) + */ +ascending?: boolean } /** * Get offers for a specific asset */ @@ -1421,7 +1449,11 @@ export type GetOffersResponse = { /** * List of offers */ -offers: OfferRecord[] } +offers: OfferRecord[]; +/** + * Total number of offers matching the filters, before paging + */ +total: number } /** * Get a specific option */ @@ -2122,8 +2154,16 @@ hidden_puzzle_hash?: string | null; */ amount: Amount } export type OfferAsset = { asset: Asset; amount: Amount; royalty: Amount; nft_royalty: NftRoyalty | null; option_assets: OptionAssets | null } +/** + * Which side of an offer a search matches + */ +export type OfferFindSide = "any" | "offered" | "requested" export type OfferRecord = { offer_id: string; offer: string; status: OfferRecordStatus; creation_timestamp: number; summary: OfferSummary } export type OfferRecordStatus = "pending" | "active" | "completed" | "cancelled" | "expired" +/** + * Offer list sort column + */ +export type OfferSortMode = "created" | "expiration" export type OfferSummary = { fee: Amount; maker: OfferAsset[]; taker: OfferAsset[]; expiration_height: number | null; expiration_timestamp: number | null } /** * Asset specification for options diff --git a/src/components/DebouncedSearchInput.test.tsx b/src/components/DebouncedSearchInput.test.tsx new file mode 100644 index 000000000..2080b704b --- /dev/null +++ b/src/components/DebouncedSearchInput.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom + +import { i18n } from '@lingui/core'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { DebouncedSearchInput } from './DebouncedSearchInput'; + +beforeAll(() => { + i18n.loadAndActivate({ locale: 'en', messages: {} }); +}); + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function input() { + return screen.getByPlaceholderText('Search'); +} + +describe('DebouncedSearchInput', () => { + it('reports the value once after the debounce delay', () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(input(), { target: { value: 'sp' } }); + fireEvent.change(input(), { target: { value: 'space' } }); + expect(onChange).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(400)); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('space'); + }); + + it('reports null for whitespace and after clearing', () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.change(input(), { target: { value: 'space' } }); + act(() => vi.advanceTimersByTime(400)); + rerender( + , + ); + + fireEvent.click(screen.getByLabelText('Clear search')); + act(() => vi.advanceTimersByTime(400)); + expect(onChange).toHaveBeenLastCalledWith(null); + + rerender( + , + ); + fireEvent.change(input(), { target: { value: ' ' } }); + act(() => vi.advanceTimersByTime(400)); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('external clear during pending debounce does not resurrect the old query', () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.change(input(), { target: { value: 'spacex' } }); + // Parent clears filters before the debounce fires. + rerender( + , + ); + act(() => vi.advanceTimersByTime(1000)); + + expect(input()).toHaveProperty('value', ''); + expect(onChange).not.toHaveBeenCalledWith('spacex'); + expect(onChange).not.toHaveBeenCalledWith('space'); + }); +}); diff --git a/src/components/DebouncedSearchInput.tsx b/src/components/DebouncedSearchInput.tsx new file mode 100644 index 000000000..780cfa892 --- /dev/null +++ b/src/components/DebouncedSearchInput.tsx @@ -0,0 +1,93 @@ +import { cn } from '@/lib/utils'; +import { t } from '@lingui/core/macro'; +import { SearchIcon, XIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +export interface DebouncedSearchInputProps { + value: string | null; + onChange: (value: string | null) => void; + placeholder: string; + disabled?: boolean; + delay?: number; + className?: string; +} + +export function DebouncedSearchInput({ + value, + onChange, + placeholder, + disabled, + delay = 400, + className, +}: DebouncedSearchInputProps) { + const [text, setText] = useState(value ?? ''); + const timerRef = useRef>(); + const valueRef = useRef(value); + const onChangeRef = useRef(onChange); + // The last value this input reported, so its echo back through `value` + // is not mistaken for an external change. + const lastSentRef = useRef(value); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + valueRef.current = value; + if (value === lastSentRef.current) return; + // External change (e.g. "Clear filters"): drop any pending keystrokes. + clearTimeout(timerRef.current); + lastSentRef.current = value; + setText(value ?? ''); + }, [value]); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const update = (next: string) => { + setText(next); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + const normalized = next.trim() === '' ? null : next; + if (normalized !== (valueRef.current || null)) { + lastSentRef.current = normalized; + onChangeRef.current(normalized); + } + }, delay); + }; + + return ( +
+
+
+ {text && ( + + )} +
+ ); +} diff --git a/src/components/MultiSelectToggle.tsx b/src/components/MultiSelectToggle.tsx new file mode 100644 index 000000000..061dfe8cd --- /dev/null +++ b/src/components/MultiSelectToggle.tsx @@ -0,0 +1,40 @@ +import { cn } from '@/lib/utils'; +import { t } from '@lingui/core/macro'; +import { CopyPlus } from 'lucide-react'; +import { Button } from './ui/button'; + +export interface MultiSelectToggleProps { + active: boolean; + onToggle: () => void; + disabled?: boolean; + label?: string; +} + +export function MultiSelectToggle({ + active, + onToggle, + disabled, + label, +}: MultiSelectToggleProps) { + const text = label ?? t`Toggle multi-select`; + + return ( + + ); +} diff --git a/src/components/NftOptions.tsx b/src/components/NftOptions.tsx index e8a3e743e..e4ffa3e78 100644 --- a/src/components/NftOptions.tsx +++ b/src/components/NftOptions.tsx @@ -1,4 +1,3 @@ -import { useDebounce } from '@/hooks/useDebounce'; import { CardSize, NftGroupMode, @@ -13,7 +12,6 @@ import { ArrowDownAz, ArrowLeftIcon, Clock2, - CopyPlus, Download, EyeIcon, EyeOff, @@ -22,12 +20,9 @@ import { Maximize2, Minimize2, Paintbrush, - SearchIcon, Settings2, UserIcon, - XIcon, } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Button } from './ui/button'; import { @@ -37,7 +32,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu'; -import { Input } from './ui/input'; +import { DebouncedSearchInput } from './DebouncedSearchInput'; +import { MultiSelectToggle } from './MultiSelectToggle'; export interface NftOptionsProps { isCollection?: boolean; @@ -69,34 +65,6 @@ export function NftOptions({ const navigate = useNavigate(); const isFilteredView = Boolean(collection_id || owner_did || minter_did); const allowSearch = group === NftGroupMode.None || isFilteredView; - const [searchValue, setSearchValue] = useState(query ?? ''); - const debouncedSearch = useDebounce(searchValue, 400); - const prevSearchRef = useRef(query); - - useEffect(() => { - setSearchValue(query ?? ''); - }, [query]); - - useEffect(() => { - // Convert empty string, undefined, and null to consistent values for comparison - const normalizedDebounced = debouncedSearch || null; - const normalizedQuery = query || null; - - // Check if queries are meaningfully different after normalization - if (normalizedDebounced !== normalizedQuery) { - const shouldResetPage = prevSearchRef.current !== debouncedSearch; - prevSearchRef.current = debouncedSearch; - - setParams({ - query: debouncedSearch || null, - ...(shouldResetPage && { page: 1 }), - }); - } - }, [debouncedSearch, query, setParams]); - - const handleClearSearch = useCallback(() => { - setSearchValue(''); - }, []); const handleBack = () => { if (collection_id) { @@ -129,37 +97,12 @@ export function NftOptions({ role='toolbar' aria-label={t`NFT filtering and sorting options`} > -
-
-
- {searchValue && ( - - )} -
+ setParams({ query: value, page: 1 })} + placeholder={t`Search NFTs...`} + disabled={!allowSearch} + />
@@ -202,19 +145,11 @@ export function NftOptions({
+ ); + + const pasteShortcut = platform() === 'macos' ? '⌘+V' : 'Ctrl+V'; + return ( <> Offers} alwaysShowChildren + paginationControls={ + !showIntro && !isOptionsVisible ? renderPagination(true) : undefined + } mobileActionItems={
- - - - navigate('/offers/make', { replace: true })} - > - Create Offer - -
+ {actionButtons} - - {offers.length > 0 && ( -
-
-
-
-
- {filteredOffers.length > 0 && ( - - - - - - - Toggle multi-select - - - - )} - {filteredOffers.length > 0 && ( - - - - - - - Delete All Filtered Offers - - - - )} -
-
- -
- {filteredOffers.map((record) => ( - toggleSelected(record.offer_id, value), - ] - : null - } - /> - ))} -
+ + ) : ( +
+ {actionButtons} + + or paste an offer with {pasteShortcut} + +
+ )} + + {/* Always mounted: useIntersectionObserver binds to this element once. */} +
+ renderPagination(false)} + className='mt-4' + /> +
+ + {!showIntro && ( +
+ {showNoMatches ? ( +
+

+ No offers match your filters. +

+
+ ) : ( + offers.map((record) => ( + + )) )} -
- + + )} - { - deleteOffers(filteredOffers.map((offer) => offer.offer_id)) - .then(updateOffers) - .catch(addError) - .finally(() => setIsDeleteAllOpen(false)); - }} - /> - {selected.length > 0 && ( { - updateOffers(); - setSelected([]); + refresh(); setMultiSelect(false); }} - onSelectAll={() => - setSelected(filteredOffers.map((offer) => offer.offer_id)) - } - onClearSelection={() => setSelected([])} + onSelectAll={() => selectAll(offers.map((offer) => offer.offer_id))} + onClearSelection={clear} /> )} From 7713d46b2d02915352e4470fc8c8332ed905e22c Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 26 Sep 2026 11:42:37 -0500 Subject: [PATCH 2/4] feat: enhance offer display with card size options and implement OfferStatusBadge component --- src/components/OfferOptions.tsx | 32 ++++- src/components/OfferRowCard.tsx | 167 +++++++++++----------- src/components/OfferStatusBadge.tsx | 57 ++++++++ src/components/OfferTileCard.test.tsx | 168 +++++++++++++++++++++++ src/components/OfferTileCard.tsx | 190 ++++++++++++++++++++++++++ src/hooks/useOfferData.test.tsx | 10 +- src/hooks/useOfferData.ts | 4 +- src/hooks/useOfferParams.ts | 25 +++- src/i18n.test.ts | 41 ++++++ src/i18n.ts | 51 +++++++ src/pages/Offers.tsx | 28 ++-- 11 files changed, 677 insertions(+), 96 deletions(-) create mode 100644 src/components/OfferStatusBadge.tsx create mode 100644 src/components/OfferTileCard.test.tsx create mode 100644 src/components/OfferTileCard.tsx create mode 100644 src/i18n.test.ts diff --git a/src/components/OfferOptions.tsx b/src/components/OfferOptions.tsx index aba61af02..01536cfc4 100644 --- a/src/components/OfferOptions.tsx +++ b/src/components/OfferOptions.tsx @@ -1,4 +1,5 @@ import { OfferFindSide } from '@/bindings'; +import { CardSize } from '@/hooks/useNftParams'; import { OfferParams, OfferStatusFilter, @@ -14,6 +15,8 @@ import { CalendarClock, Clock2, FilterIcon, + Maximize2, + Minimize2, Settings2, } from 'lucide-react'; import { DebouncedSearchInput } from './DebouncedSearchInput'; @@ -52,7 +55,7 @@ const optionsPaginationVariants = { }; export function OfferOptions({ - params: { query, status, findSide, sort, ascending }, + params: { query, status, findSide, sort, ascending, cardSize }, setParams, multiSelect, setMultiSelect, @@ -76,7 +79,7 @@ export function OfferOptions({ ]; const currentStatusLabel = statusLabels[status]; const statusLabel = t`Filter by status: ${currentStatusLabel}`; - const sortLabel = t`Sort options`; + const sortLabel = t`View options`; return (
+ + + setParams({ + cardSize: + cardSize === CardSize.Large + ? CardSize.Small + : CardSize.Large, + }) + } + > + {cardSize === CardSize.Large ? ( +
diff --git a/src/components/OfferRowCard.tsx b/src/components/OfferRowCard.tsx index e70f67531..026333abd 100644 --- a/src/components/OfferRowCard.tsx +++ b/src/components/OfferRowCard.tsx @@ -2,6 +2,7 @@ import { OfferRecord } from '@/bindings'; import { CancelOffersFlow } from '@/components/dialogs/CancelOffersFlow'; import { DeleteOfferDialog } from '@/components/dialogs/DeleteOfferDialog'; import { OfferSummaryCard } from '@/components/OfferSummaryCard'; +import { OfferTileCard, offerSummaryLabel } from '@/components/OfferTileCard'; import { SelectableCard, SelectionState } from '@/components/SelectableCard'; import { Button } from '@/components/ui/button'; import { @@ -14,6 +15,7 @@ import { } from '@/components/ui/dropdown-menu'; import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; +import { CardSize } from '@/hooks/useNftParams'; import { deleteOffers } from '@/lib/offers'; import { Trans } from '@lingui/react/macro'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; @@ -31,12 +33,14 @@ interface OfferRowCardProps { record: OfferRecord; refresh: () => void; selectionState?: SelectionState; + size?: CardSize; } export function OfferRowCard({ record, refresh, selectionState = null, + size = CardSize.Large, }: OfferRowCardProps) { const navigate = useNavigate(); const { isTransactionDisabled } = useWallet(); @@ -44,88 +48,99 @@ export function OfferRowCard({ const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isCancelOpen, setIsCancelOpen] = useState(false); + const actions = ( + + + + + + + { + e.stopPropagation(); + writeText(record.offer); + }} + > + + + { + e.stopPropagation(); + setIsDeleteOpen(true); + }} + > + + + { + e.stopPropagation(); + setIsCancelOpen(true); + }} + disabled={record.status !== 'active' || isTransactionDisabled} + > + + + + + { + e.stopPropagation(); + writeText(record.offer_id); + }} + > + + + Copy ID + + + + + + ); + return ( <> navigate(`/offers/view_saved/${record.offer_id.trim()}`)} > - - - - - - - { - e.stopPropagation(); - writeText(record.offer); - }} - > - - - { - e.stopPropagation(); - setIsDeleteOpen(true); - }} - > - - - { - e.stopPropagation(); - setIsCancelOpen(true); - }} - disabled={ - record.status !== 'active' || isTransactionDisabled - } - > - - - - - { - e.stopPropagation(); - writeText(record.offer_id); - }} - > - - - Copy ID - - - - - - } - /> + {size === CardSize.Small ? ( + + ) : ( + + )} = { + active: [CircleDot, 'text-green-600 dark:text-green-400'], + pending: [CircleDashed, 'text-muted-foreground'], + completed: [CircleCheck, 'text-primary'], + cancelled: [CircleOff, 'text-muted-foreground'], + expired: [Hourglass, 'text-amber-600 dark:text-amber-400'], +}; + +export interface OfferStatusBadgeProps { + status: OfferRecordStatus; + className?: string; +} + +/** Offer status as a colored icon and label. */ +export function OfferStatusBadge({ status, className }: OfferStatusBadgeProps) { + const [Icon, color] = statusIcons[status]; + + return ( + + + ); +} diff --git a/src/components/OfferTileCard.test.tsx b/src/components/OfferTileCard.test.tsx new file mode 100644 index 000000000..f0d7ec586 --- /dev/null +++ b/src/components/OfferTileCard.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { Asset, OfferAsset, OfferRecord } from '@/bindings'; +import { i18n } from '@lingui/core'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { OfferTileCard, offerSummaryLabel } from './OfferTileCard'; + +beforeAll(() => { + i18n.loadAndActivate({ locale: 'en', messages: {} }); +}); + +afterEach(cleanup); + +const NOW = Date.UTC(2026, 0, 10, 12, 0, 0); +const NOW_SECONDS = Math.floor(NOW / 1000); + +function asset(overrides: Partial): Asset { + return { + asset_id: 'a'.repeat(64), + name: null, + ticker: null, + precision: 3, + icon_url: null, + description: null, + is_sensitive_content: false, + is_visible: true, + revocation_address: null, + kind: 'token', + ...overrides, + }; +} + +function offered(assetValue: Asset, amount: number): OfferAsset { + return { + asset: assetValue, + amount, + royalty: 0, + nft_royalty: null, + option_assets: null, + }; +} + +const sbx = offered(asset({ name: 'Spacebucks', ticker: 'SBX' }), 1_500_000); +const nft = offered( + asset({ + asset_id: 'nft1x', + name: 'Chia Friends #42', + kind: 'nft', + precision: 0, + }), + 1, +); + +function record(overrides: Partial = {}): OfferRecord { + return { + offer_id: 'b'.repeat(64), + offer: 'offer1…', + status: 'active', + creation_timestamp: NOW_SECONDS - 2 * 3600, + summary: { + fee: 0, + maker: [sbx], + taker: [nft], + expiration_height: null, + expiration_timestamp: NOW_SECONDS + 3 * 86400, + ...overrides, + }, + }; +} + +describe('OfferTileCard', () => { + it('shows compact token amounts and NFT names without an amount', () => { + render(); + + expect(screen.getByText('1.5K')).toBeTruthy(); + expect(screen.getByText('SBX')).toBeTruthy(); + expect(screen.getByText('Chia Friends #42')).toBeTruthy(); + expect(screen.queryByText('1')).toBeNull(); + }); + + it('shows the status as text next to its icon', () => { + render(); + expect(screen.getByText('Active')).toBeTruthy(); + }); + + it('collapses extra assets on a side into +N', () => { + const extra = offered( + asset({ asset_id: 'c'.repeat(64), ticker: 'MRMT' }), + 5, + ); + const third = offered( + asset({ asset_id: 'd'.repeat(64), ticker: 'PURE' }), + 7, + ); + render( + , + ); + expect(screen.getByText('+2')).toBeTruthy(); + }); + + it('shows relative expiry and age', () => { + render(); + expect(screen.getByText('in 3d')).toBeTruthy(); + expect(screen.getByText('2h ago')).toBeTruthy(); + }); + + it('shows the block height for height-based expiry', () => { + render( + , + ); + expect(screen.getByTitle('Expires at block 123456')).toBeTruthy(); + }); + + it('shows no expiry for offers that never expire', () => { + render( + , + ); + expect(screen.queryByText('in 3d')).toBeNull(); + expect(screen.queryByTitle(/Expires at block/)).toBeNull(); + }); + + it('renders the selection checkbox only in multi-select', () => { + const { rerender } = render( + , + ); + expect(screen.queryByRole('checkbox')).toBeNull(); + + rerender( + undefined]} + />, + ); + expect(screen.getByRole('checkbox')).toBeTruthy(); + }); +}); + +describe('offerSummaryLabel', () => { + it('describes the trade for screen readers', () => { + expect(offerSummaryLabel(record(), NOW)).toBe( + 'Active offer: 1,500 SBX for Chia Friends #42, expires in 3d', + ); + }); + + it('omits expiry when the offer never expires', () => { + expect(offerSummaryLabel(record({ expiration_timestamp: null }), NOW)).toBe( + 'Active offer: 1,500 SBX for Chia Friends #42', + ); + }); +}); diff --git a/src/components/OfferTileCard.tsx b/src/components/OfferTileCard.tsx new file mode 100644 index 000000000..8c9bfc057 --- /dev/null +++ b/src/components/OfferTileCard.tsx @@ -0,0 +1,190 @@ +import { Asset, OfferAsset, OfferRecord } from '@/bindings'; +import { formatCompactNumber, formatNumber, formatRelativeTime } from '@/i18n'; +import { formatTimestamp, fromMojos } from '@/lib/utils'; +import { t } from '@lingui/core/macro'; +import BigNumber from 'bignumber.js'; +import { ArrowDownUp, Blocks, Clock3, Hourglass } from 'lucide-react'; +import { AssetIcon } from './AssetIcon'; +import { OfferStatusBadge, offerStatusLabel } from './OfferStatusBadge'; +import { SelectionState } from './SelectableCard'; +import { Checkbox } from './ui/checkbox'; + +const MAX_STACKED_ICONS = 3; + +export interface OfferTileCardProps { + record: OfferRecord; + content: React.ReactNode; + selectionState?: SelectionState; + /** Reference time in ms for relative times; defaults to the current time. */ + now?: number; +} + +/** Square, compact offer card: offered assets over requested assets. */ +export function OfferTileCard({ + record, + content, + selectionState = null, + now, +}: OfferTileCardProps) { + const { maker, taker, expiration_timestamp, expiration_height } = + record.summary; + + return ( +
+
+ {selectionState !== null && ( + + )} + +
{content}
+
+ +
+ +
+ +
+ + + +
+
+ ); +} + +function hasAmount(asset: Asset): boolean { + return asset.kind !== 'nft' && asset.kind !== 'option'; +} + +function assetAmount({ amount, royalty, asset }: OfferAsset): BigNumber { + return fromMojos(BigNumber(amount).plus(royalty), asset.precision); +} + +function assetName({ asset }: OfferAsset): string { + const name = hasAmount(asset) + ? (asset.ticker ?? asset.name) + : (asset.name ?? asset.ticker); + return name ?? t`Unknown`; +} + +interface TileSideProps { + assets: OfferAsset[]; + label: string; +} + +function TileSide({ assets, label }: TileSideProps) { + if (assets.length === 0) return null; + + const [first] = assets; + const extra = assets.length - 1; + + return ( +
+
+ {assets.slice(0, MAX_STACKED_ICONS).map((item) => ( +
+ +
+ ))} +
+
+ {hasAmount(first.asset) && ( + + {formatCompactNumber(assetAmount(first))} + + )} + + {assetName(first)} + +
+ {extra > 0 && ( + + +{extra} + + )} +
+ ); +} + +interface TileExpiryProps { + timestamp: number | null; + height: number | null; + now?: number; +} + +function TileExpiry({ timestamp, height, now }: TileExpiryProps) { + if (timestamp) { + return ( + + + ); + } + + if (height) { + return ( + + + ); + } + + // Keeps the age pinned to the right edge. + return ; +} + +function describeAssets(assets: OfferAsset[]): string { + return assets + .map((item) => + hasAmount(item.asset) + ? `${formatNumber({ + value: assetAmount(item), + maximumFractionDigits: item.asset.precision, + })} ${assetName(item)}` + : assetName(item), + ) + .join(', '); +} + +/** Spoken summary of a tile, which otherwise conveys status and times with icons. */ +export function offerSummaryLabel(record: OfferRecord, now?: number): string { + const status = offerStatusLabel(record.status); + const offeredAssets = describeAssets(record.summary.maker); + const requestedAssets = describeAssets(record.summary.taker); + const summary = t`${status} offer: ${offeredAssets} for ${requestedAssets}`; + + const expiration = record.summary.expiration_timestamp; + if (!expiration) return summary; + + const expiry = formatRelativeTime(expiration, now); + return t`${summary}, expires ${expiry}`; +} diff --git a/src/hooks/useOfferData.test.tsx b/src/hooks/useOfferData.test.tsx index e544a056a..cfc520305 100644 --- a/src/hooks/useOfferData.test.tsx +++ b/src/hooks/useOfferData.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OfferParams } from './useOfferParams'; +import { OfferQueryParams } from './useOfferParams'; const getOffers = vi.fn(); @@ -21,7 +21,7 @@ vi.mock('@/hooks/useErrors', () => ({ const { useOfferData } = await import('./useOfferData'); -const base: OfferParams = { +const base: OfferQueryParams = { page: 1, pageSize: 2, query: null, @@ -92,7 +92,7 @@ describe('useOfferData', () => { .mockReturnValueOnce(second.promise); const { result, rerender } = renderHook( - (p: OfferParams) => useOfferData(p), + (p: OfferQueryParams) => useOfferData(p), { initialProps: base, }, @@ -153,7 +153,7 @@ describe('useOfferData', () => { .mockReturnValueOnce(second.promise); const { result, rerender } = renderHook( - (p: OfferParams) => useOfferData(p), + (p: OfferQueryParams) => useOfferData(p), { initialProps: base }, ); @@ -179,7 +179,7 @@ describe('useOfferData', () => { .mockReturnValueOnce(second.promise); const { result, rerender } = renderHook( - (p: OfferParams) => useOfferData(p), + (p: OfferQueryParams) => useOfferData(p), { initialProps: base, }, diff --git a/src/hooks/useOfferData.ts b/src/hooks/useOfferData.ts index c6fd03957..55b51fa1a 100644 --- a/src/hooks/useOfferData.ts +++ b/src/hooks/useOfferData.ts @@ -1,7 +1,7 @@ import { commands, events, OfferRecord } from '@/bindings'; import { useErrors } from '@/hooks/useErrors'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { OfferParams } from './useOfferParams'; +import { OfferQueryParams } from './useOfferParams'; export function useOfferData({ page, @@ -11,7 +11,7 @@ export function useOfferData({ findSide, sort, ascending, -}: OfferParams) { +}: OfferQueryParams) { const { addError } = useErrors(); const [offers, setOffers] = useState([]); const [total, setTotal] = useState(0); diff --git a/src/hooks/useOfferParams.ts b/src/hooks/useOfferParams.ts index db0431ff3..0198933cf 100644 --- a/src/hooks/useOfferParams.ts +++ b/src/hooks/useOfferParams.ts @@ -1,4 +1,5 @@ import { OfferFindSide, OfferRecordStatus, OfferSortMode } from '@/bindings'; +import { CardSize } from '@/hooks/useNftParams'; import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useLocalStorage } from 'usehooks-ts'; @@ -8,6 +9,7 @@ const OFFER_SIDE_STORAGE_KEY = 'sage-offer-find-side'; const OFFER_SORT_STORAGE_KEY = 'sage-offer-sort'; const OFFER_ASCENDING_STORAGE_KEY = 'sage-offer-ascending'; const OFFER_PAGE_SIZE_STORAGE_KEY = 'sage-offer-page-size'; +const OFFER_CARD_SIZE_STORAGE_KEY = 'sage-offer-card-size'; export type OfferStatusFilter = OfferRecordStatus | 'all'; @@ -19,8 +21,12 @@ export interface OfferParams { findSide: OfferFindSide; sort: OfferSortMode; ascending: boolean; + cardSize: CardSize; } +/** The params that affect which offers are fetched (display settings excluded). */ +export type OfferQueryParams = Omit; + export type SetOfferParams = (params: Partial) => void; export function useOfferParams(): [OfferParams, SetOfferParams] { @@ -45,6 +51,10 @@ export function useOfferParams(): [OfferParams, SetOfferParams] { OFFER_PAGE_SIZE_STORAGE_KEY, 24, ); + const [cardSize, setCardSize] = useLocalStorage( + OFFER_CARD_SIZE_STORAGE_KEY, + CardSize.Large, + ); const params = useMemo(() => { const pageParam = searchParams.get('page'); @@ -55,8 +65,17 @@ export function useOfferParams(): [OfferParams, SetOfferParams] { const queryParam = searchParams.get('query'); const query = queryParam && queryParam.trim() !== '' ? queryParam : null; - return { page, pageSize, query, status, findSide, sort, ascending }; - }, [searchParams, pageSize, status, findSide, sort, ascending]); + return { + page, + pageSize, + query, + status, + findSide, + sort, + ascending, + cardSize, + }; + }, [searchParams, pageSize, status, findSide, sort, ascending, cardSize]); const setParams = useCallback( (newParams: Partial) => { @@ -67,6 +86,7 @@ export function useOfferParams(): [OfferParams, SetOfferParams] { if (newParams.sort !== undefined) setSort(newParams.sort); if (newParams.ascending !== undefined) setAscending(newParams.ascending); if (newParams.pageSize !== undefined) setPageSize(newParams.pageSize); + if (newParams.cardSize !== undefined) setCardSize(newParams.cardSize); setSearchParams( { @@ -84,6 +104,7 @@ export function useOfferParams(): [OfferParams, SetOfferParams] { setSort, setAscending, setPageSize, + setCardSize, ], ); diff --git a/src/i18n.test.ts b/src/i18n.test.ts new file mode 100644 index 000000000..75de9201b --- /dev/null +++ b/src/i18n.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment jsdom + +import BigNumber from 'bignumber.js'; +import { describe, expect, it } from 'vitest'; +import { formatCompactNumber, formatRelativeTime } from './i18n'; + +describe('formatCompactNumber', () => { + it('abbreviates large values', () => { + expect(formatCompactNumber(1500)).toBe('1.5K'); + expect(formatCompactNumber(new BigNumber('1234567'))).toBe('1.23M'); + }); + + it('rounds ordinary values to two decimals', () => { + expect(formatCompactNumber('12.3456')).toBe('12.35'); + }); + + it('keeps significant digits for small fractions instead of rounding to zero', () => { + expect(formatCompactNumber(0.000123456)).toBe('0.000123'); + expect(formatCompactNumber(0)).toBe('0'); + }); +}); + +describe('formatRelativeTime', () => { + const now = Date.UTC(2026, 0, 10, 12, 0, 0); + const seconds = (ms: number) => Math.floor(ms / 1000); + + it('formats future times', () => { + expect(formatRelativeTime(seconds(now) + 3 * 86400, now)).toBe('in 3d'); + expect(formatRelativeTime(seconds(now) + 90, now)).toBe('in 1m'); + }); + + it('formats past times', () => { + expect(formatRelativeTime(seconds(now) - 2 * 3600, now)).toBe('2h ago'); + expect(formatRelativeTime(seconds(now) - 45 * 86400, now)).toBe('1mo ago'); + expect(formatRelativeTime(seconds(now) - 400 * 86400, now)).toBe('1y ago'); + }); + + it('uses seconds under a minute', () => { + expect(formatRelativeTime(seconds(now) - 5, now)).toBe('5s ago'); + }); +}); diff --git a/src/i18n.ts b/src/i18n.ts index 088be3b3f..cda331ca5 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -9,6 +9,57 @@ export interface NumberFormatProps { maximumFractionDigits?: number; } +/** + * Short, locale-aware number for tight layouts (1.5K, 1.23M). Values below 1 + * keep three significant digits rather than rounding to zero. + */ +export function formatCompactNumber( + value: string | number | BigNumber, +): string { + const numberValue = new BigNumber(value).toNumber(); + + if (numberValue !== 0 && Math.abs(numberValue) < 1) { + return numberValue.toLocaleString(navigator.language, { + maximumSignificantDigits: 3, + }); + } + + return numberValue.toLocaleString(navigator.language, { + notation: 'compact', + maximumFractionDigits: 2, + }); +} + +const RELATIVE_TIME_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 365 * 86400], + ['month', 30 * 86400], + ['day', 86400], + ['hour', 3600], + ['minute', 60], +]; + +/** + * Narrow relative time for a unix timestamp in seconds ("in 3d", "2h ago"), + * truncated to the largest whole unit. + */ +export function formatRelativeTime( + timestamp: number, + now: number = Date.now(), +): string { + const deltaSeconds = timestamp - Math.floor(now / 1000); + const formatter = new Intl.RelativeTimeFormat(navigator.language, { + style: 'narrow', + }); + + for (const [unit, unitSeconds] of RELATIVE_TIME_UNITS) { + if (Math.abs(deltaSeconds) >= unitSeconds) { + return formatter.format(Math.trunc(deltaSeconds / unitSeconds), unit); + } + } + + return formatter.format(deltaSeconds, 'second'); +} + /** * Load messages for requested locale and activate it. * This function isn't part of the LinguiJS library because there are diff --git a/src/pages/Offers.tsx b/src/pages/Offers.tsx index ee57e42f2..5d88aad3b 100644 --- a/src/pages/Offers.tsx +++ b/src/pages/Offers.tsx @@ -13,6 +13,7 @@ import { Dialog, DialogTrigger } from '@/components/ui/dialog'; import { useErrors } from '@/hooks/useErrors'; import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; import { useMultiSelect } from '@/hooks/useMultiSelect'; +import { CardSize } from '@/hooks/useNftParams'; import { useOfferData } from '@/hooks/useOfferData'; import { useOfferParams } from '@/hooks/useOfferParams'; import { useScannerOrClipboard } from '@/hooks/useScannerOrClipboard'; @@ -305,7 +306,7 @@ export function Offers() { {!showIntro && ( -
+
{showNoMatches ? (

@@ -327,14 +328,23 @@ export function Offers() {

) : ( - offers.map((record) => ( - - )) +
+ {offers.map((record) => ( + + ))} +
)}
)} From 4752c8f4e4d461dce4d6254d1caba97c1969d7a8 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 26 Sep 2026 12:14:48 -0500 Subject: [PATCH 3/4] feat: update card styles for OfferRowCard and OfferSummaryCard, enhance OfferTileCard tests, and improve Offers layout --- src/components/OfferRowCard.tsx | 1 + src/components/OfferSummaryCard.tsx | 2 +- src/components/OfferTileCard.test.tsx | 30 ++++++- src/components/OfferTileCard.tsx | 116 ++++++++++++++++---------- src/pages/Offers.tsx | 2 +- 5 files changed, 103 insertions(+), 48 deletions(-) diff --git a/src/components/OfferRowCard.tsx b/src/components/OfferRowCard.tsx index 026333abd..63fad00fa 100644 --- a/src/components/OfferRowCard.tsx +++ b/src/components/OfferRowCard.tsx @@ -123,6 +123,7 @@ export function OfferRowCard({ <> +
{selectionState !== null && ( { expect(screen.getByText('+2')).toBeTruthy(); }); - it('shows relative expiry and age', () => { + it('shows only the expiry for live offers', () => { render(); expect(screen.getByText('in 3d')).toBeTruthy(); + expect(screen.queryByText('2h ago')).toBeNull(); + }); + + it('shows only the age for ended offers', () => { + render( + , + ); expect(screen.getByText('2h ago')).toBeTruthy(); + expect(screen.queryByText('in 3d')).toBeNull(); + }); + + it('shows dust amounts as a floor with the exact value in the title', () => { + const dust = offered( + asset({ asset_id: 'e'.repeat(64), ticker: 'XCH', precision: 12 }), + 1, + ); + render( + , + ); + expect(screen.getByText('<0.001').title).toBe('0.000000000001'); }); it('shows the block height for height-based expiry', () => { @@ -133,6 +160,7 @@ describe('OfferTileCard', () => { ); expect(screen.queryByText('in 3d')).toBeNull(); expect(screen.queryByTitle(/Expires at block/)).toBeNull(); + expect(screen.getByText('2h ago')).toBeTruthy(); }); it('renders the selection checkbox only in multi-select', () => { diff --git a/src/components/OfferTileCard.tsx b/src/components/OfferTileCard.tsx index 8c9bfc057..681ad3e3b 100644 --- a/src/components/OfferTileCard.tsx +++ b/src/components/OfferTileCard.tsx @@ -3,13 +3,13 @@ import { formatCompactNumber, formatNumber, formatRelativeTime } from '@/i18n'; import { formatTimestamp, fromMojos } from '@/lib/utils'; import { t } from '@lingui/core/macro'; import BigNumber from 'bignumber.js'; -import { ArrowDownUp, Blocks, Clock3, Hourglass } from 'lucide-react'; +import { Blocks, Clock3, Hourglass } from 'lucide-react'; import { AssetIcon } from './AssetIcon'; import { OfferStatusBadge, offerStatusLabel } from './OfferStatusBadge'; import { SelectionState } from './SelectableCard'; import { Checkbox } from './ui/checkbox'; -const MAX_STACKED_ICONS = 3; +const DUST_THRESHOLD = 0.001; export interface OfferTileCardProps { record: OfferRecord; @@ -26,11 +26,10 @@ export function OfferTileCard({ selectionState = null, now, }: OfferTileCardProps) { - const { maker, taker, expiration_timestamp, expiration_height } = - record.summary; + const { maker, taker } = record.summary; return ( -
+
{selectionState !== null && ( {content}
-
+
-