app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 402ec894e69454abfe948f1ff115573689a44771
parent 2f16e2ed5a9d04776e5c3aadaa82ab94238d9d39
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 17:04:21 +0000

feat: add buyer browse and search surfaces

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 230++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/launchers/desktop/src/source_guards.rs | 17+++++++++++++++--
Mcrates/launchers/desktop/src/window.rs | 504++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/shared/i18n/src/keys.rs | 6++++++
Mi18n/locales/en/messages.json | 6++++++
5 files changed, 721 insertions(+), 42 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths}; use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, - FarmId, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, + FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalSection, @@ -188,6 +188,20 @@ impl DesktopAppRuntime { self.lock_state_mut().select_farmer_section(section) } + pub fn set_personal_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .set_personal_search_query(search_query) + } + + pub fn set_personal_search_fulfillment_method( + &self, + method: FarmOrderMethod, + enabled: bool, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .set_personal_search_fulfillment_method(method, enabled) + } + pub fn set_products_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> { self.lock_state_mut() .set_products_search_query(search_query) @@ -732,6 +746,37 @@ impl DesktopAppRuntimeState { section_changed || editor_changed } + fn set_personal_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> { + let query = self.state_store.personal_projection().search.query.clone(); + if query.search_query == search_query { + return Ok(false); + } + + self.replace_personal_search_query(BuyerSearchScreenQueryState::new( + search_query, + query.fulfillment_methods, + )) + } + + fn set_personal_search_fulfillment_method( + &mut self, + method: FarmOrderMethod, + enabled: bool, + ) -> Result<bool, AppSqliteError> { + let mut query = self.state_store.personal_projection().search.query.clone(); + let changed = if enabled { + query.fulfillment_methods.insert(method) + } else { + query.fulfillment_methods.remove(&method) + }; + + if !changed { + return Ok(false); + } + + self.replace_personal_search_query(query) + } + fn set_products_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> { let query = self.state_store.products_projection().query.clone(); if query.search_query == search_query { @@ -1354,6 +1399,40 @@ impl DesktopAppRuntimeState { .ok_or(DesktopAppRuntimeFarmRulesError::RuntimeUnavailable) } + fn replace_personal_search_query( + &mut self, + query: BuyerSearchScreenQueryState, + ) -> Result<bool, AppSqliteError> { + let search_listings = self.load_personal_listings_for_query(&query)?; + let mut personal_projection = self.state_store.personal_projection().clone(); + + if personal_projection.search.query == query + && personal_projection.search.listings == search_listings + { + return Ok(false); + } + + personal_projection.search.query = query; + personal_projection.search.listings = search_listings; + + Ok(self + .state_store + .apply_in_memory(AppStateCommand::replace_personal_projection( + personal_projection, + ))) + } + + fn load_personal_listings_for_query( + &self, + query: &BuyerSearchScreenQueryState, + ) -> Result<radroots_app_models::BuyerListingsProjection, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(Default::default()); + }; + + sqlite_store.load_buyer_listings(&query.search_query, &query.fulfillment_methods) + } + fn replace_products_query( &mut self, query: ProductsScreenQueryState, @@ -1860,6 +1939,7 @@ fn normalize_pickup_location_defaults(pickup_locations: &mut [PickupLocationReco #[cfg(test)] mod tests { use std::{ + collections::BTreeSet, fs, path::PathBuf, sync::Arc, @@ -2496,6 +2576,154 @@ mod tests { } #[test] + fn runtime_personal_search_queries_refresh_repository_backed_marketplace_projection() { + let runtime = memory_runtime(); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + let pickup_location_id = PickupLocationId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let sql = format!( + "insert into pickup_locations ( + id, + farm_id, + label, + address_line, + directions, + is_default, + created_at, + updated_at + ) values ( + '{pickup_location_id}', + '{farm_id}', + 'North barn', + '14 County Road', + null, + 1, + '2026-04-20T08:00:00Z', + '2026-04-20T08:00:00Z' + ); + insert into fulfillment_windows ( + id, + farm_id, + starts_at, + ends_at, + capacity_limit, + created_at, + updated_at, + pickup_location_id, + label, + order_cutoff_at + ) values ( + '{fulfillment_window_id}', + '{farm_id}', + '2099-04-18T16:00:00Z', + '2099-04-18T18:00:00Z', + null, + '2099-04-18T16:00:00Z', + '2099-04-18T16:00:00Z', + '{pickup_location_id}', + 'Friday pickup', + '2099-04-17T18:00:00Z' + ); + update account_farm_setups + set + pickup_enabled = 1, + delivery_enabled = 0, + shipping_enabled = 0, + saved_farm_id = '{farm_id}', + saved_farm_display_name = 'North field farm', + saved_farm_readiness = 'ready', + updated_at = '2026-04-20T08:00:00Z' + where account_id = '{account_id}';" + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&sql) + .expect("buyer search workspace should seed"); + let salad_mix_id = seed_product( + &runtime, + farm_id, + "Salad mix", + "Spring blend", + "published", + Some(8), + "2026-04-20T09:00:00Z", + ); + let pea_shoots_id = seed_product( + &runtime, + farm_id, + "Pea shoots", + "Tray-grown", + "published", + Some(4), + "2026-04-20T09:30:00Z", + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&format!( + "update products + set availability_window_id = '{fulfillment_window_id}' + where id in ('{salad_mix_id}', '{pea_shoots_id}')" + )) + .expect("buyer-visible products should attach a fulfillment window"); + + let _ = runtime + .select_local_account(account_id.as_str()) + .expect("account should refresh after buyer workspace seeding"); + let summary = runtime.summary(); + assert_eq!(summary.personal_projection.search.listings.rows.len(), 2); + assert!( + summary + .personal_projection + .search + .query + .fulfillment_methods + .is_empty() + ); + + assert!( + runtime + .set_personal_search_query("pea") + .expect("buyer search query should apply") + ); + let searched = runtime.summary(); + assert_eq!(searched.personal_projection.search.listings.rows.len(), 1); + assert_eq!( + searched.personal_projection.search.listings.rows[0].title, + "Pea shoots" + ); + + assert!( + runtime + .set_personal_search_fulfillment_method(FarmOrderMethod::Pickup, true) + .expect("buyer fulfillment filter should apply") + ); + let filtered = runtime.summary(); + assert_eq!( + filtered + .personal_projection + .search + .query + .fulfillment_methods, + BTreeSet::from([FarmOrderMethod::Pickup]) + ); + assert_eq!(filtered.personal_projection.search.listings.rows.len(), 1); + assert_eq!( + filtered.personal_projection.search.listings.rows[0] + .next_fulfillment_window_label + .as_deref(), + Some("Friday pickup") + ); + } + + #[test] fn runtime_products_queries_refresh_the_repository_backed_projection() { let runtime = memory_runtime(); diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -32,8 +32,13 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "account-open-workspace", "account-log-out", "account-more", + "buyer", "bunker uri", "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", + "buyer.fulfillment_filter_update_failed", + "buyer.search_query_update_failed", + "failed to update buyer fulfillment filter", + "failed to update buyer search query", "failed to add relay `{relay_url}`: {error}", "failed to load farm settings projection", "failed to mark order completed", @@ -77,6 +82,9 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer-nav-orders", "buyer-nav-search", "buyer-orders-scroll", + "personal-search-delivery", + "personal-search-pickup", + "personal-search-shipping", "buyer-search-scroll", "home-today-open-pack-day", "home-today-order-open", @@ -91,6 +99,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "identity", "none", "npub1", + "guest", "orders", "pack_day", "pack_day.route_failed", @@ -181,8 +190,6 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "startup-title-starting", "wss://relay.radroots.example", "{} items are ready in your cart{}.", - "{} local listings are already loaded on this device.", - "{} local listings are available to search from the shared marketplace source.", "{} local orders are already available on this device.", "{quantity} {unit_label}", "{} {}", @@ -236,6 +243,12 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::HomeNavToday", "AppTextKey::HomeNavProducts", "AppTextKey::HomeNavOrders", + "AppTextKey::PersonalSearchFiltersTitle", + "AppTextKey::PersonalSearchPlaceholder", + "AppTextKey::PersonalBrowseEmptyTitle", + "AppTextKey::PersonalBrowseEmptyBody", + "AppTextKey::PersonalSearchEmptyTitle", + "AppTextKey::PersonalSearchEmptyBody", "AppTextKey::PersonalBrowsePlaceholderBody", "AppTextKey::PersonalSearchPlaceholderBody", "AppTextKey::PersonalCartPlaceholderBody", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -11,16 +11,17 @@ use gpui_component::{ use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ - AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, - FarmOrderMethod, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, - FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, - FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, - LoggedOutStartupPhase, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, - OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListRow, PackDayPackListRow, - PackDayProductTotalRow, PackDayRosterRow, PersonalEntryState, PersonalSection, - PickupLocationId, PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, - ProductListRow, ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, - ProductsSort, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, + AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerListingRow, FarmId, + FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadinessBlocker, + FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, + FarmTimingConflictKind, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, + FulfillmentWindowSummary, LoggedOutStartupPhase, OrderDetailItemRow, OrderDetailProjection, + OrderId, OrderListRow, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListRow, + PackDayPackListRow, PackDayProductTotalRow, PackDayRosterRow, PersonalEntryState, + PersonalSection, PickupLocationId, PickupLocationRecord, ProductAttentionState, + ProductEditorDraft, ProductId, ProductListRow, ProductPricePresentation, ProductPublishBlocker, + ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, + TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -52,7 +53,7 @@ use radroots_app_ui::{ utility_title_row, }; use radroots_nostr::prelude::RadrootsNostrClient; -use std::{sync::Arc, time::Duration}; +use std::{collections::BTreeSet, sync::Arc, time::Duration}; use tracing::error; use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary}; @@ -185,6 +186,7 @@ pub struct HomeView { startup_signer_task_token: u64, startup_signer_recovery_attempted: bool, farm_setup_form: Option<FarmSetupFormState>, + personal_search: Option<PersonalSearchState>, products_search: Option<ProductsSearchState>, products_stock_editor: Option<ProductsStockEditorState>, product_editor_form: Option<ProductEditorFormState>, @@ -230,6 +232,7 @@ impl HomeView { startup_signer_task_token: 0, startup_signer_recovery_attempted: false, farm_setup_form: None, + personal_search: None, products_search: None, products_stock_editor: None, product_editor_form: None, @@ -731,6 +734,52 @@ impl HomeView { } } + fn sync_personal_search( + &mut self, + runtime_summary: &DesktopAppRuntimeSummary, + window: &mut Window, + cx: &mut Context<Self>, + ) { + if home_stage(runtime_summary) != HomeStage::BuyerWorkspace + || selected_personal_section(runtime_summary) != PersonalSection::Search + { + self.personal_search = None; + return; + } + + let workspace_id = runtime_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.clone()) + .unwrap_or_else(|| "guest".to_owned()); + let search_query = runtime_summary + .personal_projection + .search + .query + .search_query + .as_str(); + let should_reset = self + .personal_search + .as_ref() + .map(|state| state.workspace_id != workspace_id) + .unwrap_or(true); + + if should_reset { + self.personal_search = Some(PersonalSearchState::new( + workspace_id, + search_query, + window, + cx, + )); + return; + } + + if let Some(personal_search) = self.personal_search.as_mut() { + personal_search.sync(search_query, window, cx); + } + } + fn sync_products_stock_editor(&mut self, runtime_summary: &DesktopAppRuntimeSummary) { let Some(editor) = self.products_stock_editor.as_ref() else { return; @@ -935,6 +984,56 @@ impl HomeView { } } + fn handle_personal_search_input_event( + &mut self, + state: &Entity<InputState>, + event: &InputEvent, + _: &mut Window, + cx: &mut Context<Self>, + ) { + if !matches!(event, InputEvent::Change) { + return; + } + + let value = state.read(cx).value().to_string(); + match self.runtime.set_personal_search_query(value.as_str()) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "buyer", + event = "buyer.search_query_update_failed", + error = %runtime_error, + "failed to update buyer search query" + ); + } + } + } + + fn toggle_personal_search_fulfillment_method( + &mut self, + method: FarmOrderMethod, + enabled: bool, + cx: &mut Context<Self>, + ) { + match self + .runtime + .set_personal_search_fulfillment_method(method, enabled) + { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "buyer", + event = "buyer.fulfillment_filter_update_failed", + error = %runtime_error, + method = method.storage_key(), + "failed to update buyer fulfillment filter" + ); + } + } + } + fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) { match self.runtime.select_products_filter(filter) { Ok(true) => { @@ -1734,8 +1833,10 @@ impl HomeView { ) -> AnyElement { let selected_personal_section = selected_personal_section(runtime); let main_content = match selected_personal_section { - PersonalSection::Browse => buyer_browse_placeholder(runtime).into_any_element(), - PersonalSection::Search => buyer_search_placeholder(runtime).into_any_element(), + PersonalSection::Browse => self.render_buyer_browse_content(runtime).into_any_element(), + PersonalSection::Search => self + .render_buyer_search_content(runtime, cx) + .into_any_element(), PersonalSection::Cart => buyer_cart_placeholder(runtime).into_any_element(), PersonalSection::Orders => buyer_orders_placeholder(runtime).into_any_element(), }; @@ -1781,6 +1882,145 @@ impl HomeView { .into_any_element() } + fn render_buyer_browse_content(&mut self, runtime: &DesktopAppRuntimeSummary) -> AnyElement { + let listings = &runtime.personal_projection.browse.listings.rows; + + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(buyer_workspace_title_block( + AppTextKey::HomeNavBrowse, + AppTextKey::PersonalBrowsePlaceholderBody, + )) + .child(if listings.is_empty() { + home_empty_state_card( + AppTextKey::PersonalBrowseEmptyTitle, + AppTextKey::PersonalBrowseEmptyBody, + ) + .into_any_element() + } else { + buyer_listings_feed(listings).into_any_element() + }) + .into_any_element() + } + + fn render_buyer_search_content( + &mut self, + runtime: &DesktopAppRuntimeSummary, + cx: &mut Context<Self>, + ) -> AnyElement { + let query = &runtime.personal_projection.search.query; + let listings = &runtime.personal_projection.search.listings.rows; + + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(buyer_workspace_title_block( + AppTextKey::HomeNavSearch, + AppTextKey::PersonalSearchPlaceholderBody, + )) + .child( + home_card( + app_shared_text(AppTextKey::PersonalSearchFiltersTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .when_some(self.personal_search.as_ref(), |this, personal_search| { + this.child( + app_text_input(&personal_search.input, false) + .cleanable(true) + .w_full(), + ) + }) + .child( + app_cluster(8.0) + .child(choice_button( + "personal-search-pickup", + app_shared_text(AppTextKey::HomeFarmSetupOrderMethodPickup), + query.fulfillment_methods.contains(&FarmOrderMethod::Pickup), + cx.listener(|this, _, _, cx| { + let enabled = !this + .runtime + .summary() + .personal_projection + .search + .query + .fulfillment_methods + .contains(&FarmOrderMethod::Pickup); + this.toggle_personal_search_fulfillment_method( + FarmOrderMethod::Pickup, + enabled, + cx, + ) + }), + cx, + )) + .child(choice_button( + "personal-search-delivery", + app_shared_text(AppTextKey::HomeFarmSetupOrderMethodDelivery), + query + .fulfillment_methods + .contains(&FarmOrderMethod::Delivery), + cx.listener(|this, _, _, cx| { + let enabled = !this + .runtime + .summary() + .personal_projection + .search + .query + .fulfillment_methods + .contains(&FarmOrderMethod::Delivery); + this.toggle_personal_search_fulfillment_method( + FarmOrderMethod::Delivery, + enabled, + cx, + ) + }), + cx, + )) + .child(choice_button( + "personal-search-shipping", + app_shared_text(AppTextKey::HomeFarmSetupOrderMethodShipping), + query + .fulfillment_methods + .contains(&FarmOrderMethod::Shipping), + cx.listener(|this, _, _, cx| { + let enabled = !this + .runtime + .summary() + .personal_projection + .search + .query + .fulfillment_methods + .contains(&FarmOrderMethod::Shipping); + this.toggle_personal_search_fulfillment_method( + FarmOrderMethod::Shipping, + enabled, + cx, + ) + }), + cx, + )), + ), + ) + .into_any_element(), + ) + .child(if listings.is_empty() { + home_empty_state_card( + AppTextKey::PersonalSearchEmptyTitle, + AppTextKey::PersonalSearchEmptyBody, + ) + .into_any_element() + } else { + buyer_listings_feed(listings).into_any_element() + }) + .into_any_element() + } + fn render_farmer_workspace( &mut self, runtime: &DesktopAppRuntimeSummary, @@ -2354,6 +2594,7 @@ impl Render for HomeView { let runtime_summary = self.runtime.summary(); self.sync_startup_signer_entry(&runtime_summary, window, cx); self.sync_farm_setup_form(&runtime_summary, window, cx); + self.sync_personal_search(&runtime_summary, window, cx); self.sync_products_search(&runtime_summary, window, cx); self.sync_products_stock_editor(&runtime_summary); self.sync_product_editor_form(&runtime_summary, window, cx); @@ -2438,6 +2679,45 @@ impl FarmSetupFormState { } } +struct PersonalSearchState { + workspace_id: String, + input: Entity<InputState>, + _input_subscription: Subscription, +} + +impl PersonalSearchState { + fn new( + workspace_id: String, + search_query: &str, + window: &mut Window, + cx: &mut Context<HomeView>, + ) -> Self { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(app_shared_text(AppTextKey::PersonalSearchPlaceholder)) + .default_value(search_query.to_owned()) + }); + let input_subscription = + cx.subscribe_in(&input, window, HomeView::handle_personal_search_input_event); + + Self { + workspace_id, + input, + _input_subscription: input_subscription, + } + } + + fn sync(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<HomeView>) { + if self.input.read(cx).value().as_ref() == search_query { + return; + } + + self.input.update(cx, |input, cx| { + input.set_value(search_query.to_owned(), window, cx); + }); + } +} + struct ProductsSearchState { account_id: String, input: Entity<InputState>, @@ -4989,6 +5269,178 @@ fn shell_account_label(runtime: &DesktopAppRuntimeSummary) -> String { .unwrap_or_else(|| app_shared_text(AppTextKey::HomeHeaderGuestLabel).to_string()) } +fn buyer_workspace_title_block(title_key: AppTextKey, body_key: AppTextKey) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .gap(px(4.0)) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(app_shared_text(title_key)), + ) + .child( + div() + .w_full() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(body_key)), + ) +} + +fn buyer_listings_feed(rows: &[BuyerListingRow]) -> impl IntoElement { + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .children(rows.iter().map(buyer_listing_card).collect::<Vec<_>>()) +} + +fn buyer_listing_card(row: &BuyerListingRow) -> AnyElement { + let subtitle = row + .subtitle + .as_deref() + .map(str::trim) + .filter(|subtitle| !subtitle.is_empty()) + .map(str::to_owned); + + app_surface_card( + div() + .w_full() + .min_w_0() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child( + div() + .w_full() + .min_w_0() + .flex() + .items_start() + .justify_between() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(4.0)) + .child( + div() + .w_full() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::BOLD) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(product_display_title(row.title.as_str())), + ) + .child( + div() + .w_full() + .min_w_0() + .text_size(px(APP_UI_THEME + .foundation + .typography + .utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.accent)) + .child(row.farm_display_name.clone()), + ) + .when_some(subtitle, |this, subtitle| { + this.child( + div() + .w_full() + .min_w_0() + .text_size(px(APP_UI_THEME + .foundation + .typography + .utility_title_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(subtitle), + ) + }), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(buyer_listing_price_text(&row.price)), + ), + ) + .child( + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(buyer_listing_chip(buyer_listing_next_window_text(row))) + .child(buyer_listing_chip(buyer_listing_fulfillment_methods_text( + &row.fulfillment_methods, + ))) + .child(buyer_listing_chip( + buyer_listing_stock_or_availability_text(row), + )), + ), + ) + .into_any_element() +} + +fn buyer_listing_chip(content: impl Into<SharedString>) -> impl IntoElement { + div() + .flex() + .items_center() + .min_w_0() + .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) + .rounded(px(APP_UI_THEME.foundation.radii.small_px)) + .px(px(8.0)) + .py(px(6.0)) + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_height(relative(1.1)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(content.into()) +} + +fn buyer_listing_next_window_text(row: &BuyerListingRow) -> String { + row.next_fulfillment_window_label + .clone() + .unwrap_or_else(|| row.availability.label.clone()) +} + +fn buyer_listing_fulfillment_methods_text(methods: &BTreeSet<FarmOrderMethod>) -> String { + if methods.is_empty() { + return app_shared_text(AppTextKey::ValueNone).to_string(); + } + + methods + .iter() + .map(|method| app_shared_text(home_farm_order_method_label_key(*method)).to_string()) + .collect::<Vec<_>>() + .join(", ") +} + +fn buyer_listing_stock_or_availability_text(row: &BuyerListingRow) -> String { + match row.stock.quantity { + Some(quantity) => match row.stock.unit_label.as_deref() { + Some(unit_label) if !unit_label.trim().is_empty() => format!("{quantity} {unit_label}"), + Some(_) | None => quantity.to_string(), + }, + None => row.availability.label.clone(), + } +} + +fn buyer_listing_price_text(price: &ProductPricePresentation) -> String { + let dollars = price.amount_minor_units / 100; + let cents = price.amount_minor_units % 100; + + format!("${dollars}.{cents:02} / {}", price.unit_label) +} + fn buyer_surface_placeholder( title_key: AppTextKey, body_key: AppTextKey, @@ -5008,32 +5460,6 @@ fn buyer_surface_placeholder( .into_any_element() } -fn buyer_browse_placeholder(runtime: &DesktopAppRuntimeSummary) -> AnyElement { - let detail = (!runtime.personal_projection.browse.listings.rows.is_empty()).then_some(format!( - "{} local listings are already loaded on this device.", - runtime.personal_projection.browse.listings.rows.len() - )); - - buyer_surface_placeholder( - AppTextKey::HomeNavBrowse, - AppTextKey::PersonalBrowsePlaceholderBody, - detail, - ) -} - -fn buyer_search_placeholder(runtime: &DesktopAppRuntimeSummary) -> AnyElement { - let detail = (!runtime.personal_projection.search.listings.rows.is_empty()).then_some(format!( - "{} local listings are available to search from the shared marketplace source.", - runtime.personal_projection.search.listings.rows.len() - )); - - buyer_surface_placeholder( - AppTextKey::HomeNavSearch, - AppTextKey::PersonalSearchPlaceholderBody, - detail, - ) -} - fn buyer_cart_placeholder(runtime: &DesktopAppRuntimeSummary) -> AnyElement { let cart = &runtime.personal_projection.cart.cart; let detail = if cart.lines.is_empty() { diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -100,6 +100,12 @@ define_app_text_keys! { HomeTodayEmptyNoFarmBody => "home.today.empty.no_farm.body", HomeTodayEmptyQuietTitle => "home.today.empty.quiet.title", HomeTodayEmptyQuietBody => "home.today.empty.quiet.body", + PersonalSearchFiltersTitle => "personal.search.filters.title", + PersonalSearchPlaceholder => "personal.search.placeholder", + PersonalBrowseEmptyTitle => "personal.browse.empty.title", + PersonalBrowseEmptyBody => "personal.browse.empty.body", + PersonalSearchEmptyTitle => "personal.search.empty.title", + PersonalSearchEmptyBody => "personal.search.empty.body", PersonalBrowsePlaceholderBody => "personal.browse.placeholder.body", PersonalSearchPlaceholderBody => "personal.search.placeholder.body", PersonalCartPlaceholderBody => "personal.cart.placeholder.body", diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -79,6 +79,12 @@ "home.today.empty.no_farm.body": "Create a farm to start using the farmer workspace.", "home.today.empty.quiet.title": "Nothing urgent right now", "home.today.empty.quiet.body": "Orders, stock, and drafts will appear here when they need attention.", + "personal.search.filters.title": "Filter by fulfillment", + "personal.search.placeholder": "Search products or farms", + "personal.browse.empty.title": "No listings yet", + "personal.browse.empty.body": "Marketplace listings will appear here when farms publish them.", + "personal.search.empty.title": "No matches found", + "personal.search.empty.body": "Try a different search or clear a fulfillment filter.", "personal.browse.placeholder.body": "Products from local farms will appear here when they are available.", "personal.search.placeholder.body": "Search will use the same marketplace listings and stay focused on products, farms, and pickup options.", "personal.cart.placeholder.body": "Add items from one farm to start an order.",