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:
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.",