commit 47a33fc94968da3e89809548d290d514545bb35b
parent 402ec894e69454abfe948f1ff115573689a44771
Author: triesap <tyson@radroots.org>
Date: Mon, 20 Apr 2026 17:28:35 +0000
marketplace: add buyer detail cart handoff
- open buyer product detail cards from browse and search listings
- keep quantity and replace-cart confirmation inside the buyer detail panel
- route add-to-cart through the sqlite-backed cart projection and cart section
- add localized buyer detail copy, shared card button primitives, and runtime coverage
Diffstat:
10 files changed, 1193 insertions(+), 52 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -5,13 +5,14 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths};
use radroots_app_models::{
ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate,
- FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft,
- FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId,
- LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection,
- OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalSection,
- PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection,
- ProductsSort, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
- TodayAgendaProjection,
+ BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection,
+ BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness,
+ FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection,
+ FulfillmentWindowId, LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter,
+ OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState,
+ PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter,
+ ProductsListProjection, ProductsSort, SettingsAccountProjection, SettingsPreference,
+ SettingsSection, ShellSection, TodayAgendaProjection,
};
use radroots_app_remote_signer::{
RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession,
@@ -188,6 +189,43 @@ impl DesktopAppRuntime {
self.lock_state_mut().select_farmer_section(section)
}
+ pub fn open_personal_product_detail(
+ &self,
+ section: PersonalSection,
+ product_id: ProductId,
+ ) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut()
+ .open_personal_product_detail(section, product_id)
+ }
+
+ pub fn close_personal_product_detail(&self, section: PersonalSection) -> bool {
+ self.lock_state_mut().close_personal_product_detail(section)
+ }
+
+ pub fn increase_personal_product_quantity(&self, section: PersonalSection) -> bool {
+ self.lock_state_mut()
+ .adjust_personal_product_quantity(section, 1)
+ }
+
+ pub fn decrease_personal_product_quantity(&self, section: PersonalSection) -> bool {
+ self.lock_state_mut()
+ .adjust_personal_product_quantity(section, -1)
+ }
+
+ pub fn add_personal_product_to_cart(
+ &self,
+ section: PersonalSection,
+ replace_existing: bool,
+ ) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut()
+ .add_personal_product_to_cart(section, replace_existing)
+ }
+
+ pub fn clear_personal_cart_replace_confirmation(&self) -> bool {
+ self.lock_state_mut()
+ .clear_personal_cart_replace_confirmation()
+ }
+
pub fn set_personal_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> {
self.lock_state_mut()
.set_personal_search_query(search_query)
@@ -746,6 +784,131 @@ impl DesktopAppRuntimeState {
section_changed || editor_changed
}
+ fn open_personal_product_detail(
+ &mut self,
+ section: PersonalSection,
+ product_id: ProductId,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let Some(detail) = sqlite_store.load_buyer_product_detail(product_id)? else {
+ return Ok(false);
+ };
+
+ let section_changed = matches!(section, PersonalSection::Browse | PersonalSection::Search)
+ && self.select_personal_section(section);
+ let detail_changed = self.set_personal_product_detail(section, Some(detail));
+
+ Ok(section_changed || detail_changed)
+ }
+
+ fn close_personal_product_detail(&mut self, section: PersonalSection) -> bool {
+ self.set_personal_product_detail(section, None)
+ }
+
+ fn adjust_personal_product_quantity(&mut self, section: PersonalSection, delta: i32) -> bool {
+ self.mutate_personal_projection(|projection| {
+ let Some(detail) = personal_detail_mut(projection, section) else {
+ return false;
+ };
+ let next_quantity = if delta.is_negative() {
+ detail
+ .selected_quantity
+ .saturating_sub(delta.unsigned_abs())
+ } else {
+ detail.selected_quantity.saturating_add(delta as u32)
+ };
+
+ if next_quantity == 0 || next_quantity == detail.selected_quantity {
+ return false;
+ }
+
+ detail.selected_quantity = next_quantity;
+ true
+ })
+ }
+
+ fn add_personal_product_to_cart(
+ &mut self,
+ section: PersonalSection,
+ replace_existing: bool,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let Some(detail) =
+ personal_detail(self.state_store.personal_projection(), section).cloned()
+ else {
+ return Ok(false);
+ };
+ let buyer_context = self.state_store.identity_projection().buyer_context();
+ let current_cart = sqlite_store.load_buyer_cart(&buyer_context)?;
+
+ if !replace_existing
+ && !current_cart.is_empty()
+ && current_cart.farm_id != Some(detail.listing.farm_id)
+ {
+ let current_farm_display_name = current_cart
+ .farm_display_name
+ .clone()
+ .or_else(|| {
+ current_cart
+ .lines
+ .first()
+ .map(|line| line.farm_display_name.clone())
+ })
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart farm display name is missing",
+ })?;
+ let replace_confirmation = BuyerCartReplaceConfirmationProjection {
+ current_farm_display_name,
+ incoming_farm_display_name: detail.listing.farm_display_name.clone(),
+ };
+
+ return Ok(self.mutate_personal_projection(|projection| {
+ let cart = &mut projection.cart.cart;
+ if cart.replace_confirmation.as_ref() == Some(&replace_confirmation) {
+ return false;
+ }
+
+ cart.replace_confirmation = Some(replace_confirmation);
+ true
+ }));
+ }
+
+ let next_cart = next_buyer_cart_for_detail(current_cart, &detail, replace_existing)?;
+ sqlite_store.replace_buyer_cart(&buyer_context, &next_cart)?;
+ let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?;
+ let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?;
+ let cart_changed = self.mutate_personal_projection(|projection| {
+ let mut changed = false;
+ if projection.cart.cart != refreshed_cart {
+ projection.cart.cart = refreshed_cart.clone();
+ changed = true;
+ }
+ if projection.cart.checkout != refreshed_checkout {
+ projection.cart.checkout = refreshed_checkout.clone();
+ changed = true;
+ }
+ changed
+ });
+ let section_changed = self.select_personal_section(PersonalSection::Cart);
+
+ Ok(cart_changed || section_changed)
+ }
+
+ fn clear_personal_cart_replace_confirmation(&mut self) -> bool {
+ self.mutate_personal_projection(|projection| {
+ if projection.cart.cart.replace_confirmation.is_none() {
+ return false;
+ }
+
+ projection.cart.cart.replace_confirmation = None;
+ true
+ })
+ }
+
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 {
@@ -1399,6 +1562,39 @@ impl DesktopAppRuntimeState {
.ok_or(DesktopAppRuntimeFarmRulesError::RuntimeUnavailable)
}
+ fn mutate_personal_projection(
+ &mut self,
+ mutator: impl FnOnce(&mut PersonalWorkspaceProjection) -> bool,
+ ) -> bool {
+ let mut projection = self.state_store.personal_projection().clone();
+ if !mutator(&mut projection) {
+ return false;
+ }
+
+ self.state_store
+ .apply_in_memory(AppStateCommand::replace_personal_projection(projection))
+ }
+
+ fn set_personal_product_detail(
+ &mut self,
+ section: PersonalSection,
+ detail: Option<BuyerProductDetailProjection>,
+ ) -> bool {
+ self.mutate_personal_projection(|projection| {
+ let current_detail = match section {
+ PersonalSection::Browse => &mut projection.browse.detail,
+ PersonalSection::Search => &mut projection.search.detail,
+ PersonalSection::Cart | PersonalSection::Orders => return false,
+ };
+ if *current_detail == detail {
+ return false;
+ }
+
+ *current_detail = detail;
+ true
+ })
+ }
+
fn replace_personal_search_query(
&mut self,
query: BuyerSearchScreenQueryState,
@@ -1838,6 +2034,129 @@ fn load_selected_account_context(
})
}
+fn personal_detail(
+ projection: &PersonalWorkspaceProjection,
+ section: PersonalSection,
+) -> Option<&BuyerProductDetailProjection> {
+ match section {
+ PersonalSection::Browse => projection.browse.detail.as_ref(),
+ PersonalSection::Search => projection.search.detail.as_ref(),
+ PersonalSection::Cart | PersonalSection::Orders => None,
+ }
+}
+
+fn personal_detail_mut(
+ projection: &mut PersonalWorkspaceProjection,
+ section: PersonalSection,
+) -> Option<&mut BuyerProductDetailProjection> {
+ match section {
+ PersonalSection::Browse => projection.browse.detail.as_mut(),
+ PersonalSection::Search => projection.search.detail.as_mut(),
+ PersonalSection::Cart | PersonalSection::Orders => None,
+ }
+}
+
+fn next_buyer_cart_for_detail(
+ mut current_cart: BuyerCartProjection,
+ detail: &BuyerProductDetailProjection,
+ replace_existing: bool,
+) -> Result<BuyerCartProjection, AppSqliteError> {
+ let incoming_line = buyer_cart_line_from_detail(detail)?;
+ let current_farm_id = current_cart.farm_id;
+ let should_replace_lines = replace_existing
+ || current_cart.is_empty()
+ || current_farm_id != Some(detail.listing.farm_id);
+
+ if should_replace_lines {
+ current_cart.lines.clear();
+ }
+
+ current_cart.farm_id = Some(detail.listing.farm_id);
+ current_cart.farm_display_name = Some(detail.listing.farm_display_name.clone());
+ current_cart.replace_confirmation = None;
+
+ if let Some(existing_line) = current_cart
+ .lines
+ .iter_mut()
+ .find(|line| line.product_id == incoming_line.product_id)
+ {
+ existing_line.quantity = existing_line
+ .quantity
+ .checked_add(incoming_line.quantity)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart quantity overflow",
+ })?;
+ existing_line.line_total_minor_units = existing_line
+ .unit_price
+ .amount_minor_units
+ .checked_mul(existing_line.quantity)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart line total overflow",
+ })?;
+ } else {
+ current_cart.lines.push(incoming_line);
+ }
+
+ refresh_buyer_cart_totals(&mut current_cart)?;
+
+ Ok(current_cart)
+}
+
+fn buyer_cart_line_from_detail(
+ detail: &BuyerProductDetailProjection,
+) -> Result<BuyerCartLineProjection, AppSqliteError> {
+ Ok(BuyerCartLineProjection {
+ product_id: detail.listing.product_id,
+ farm_id: detail.listing.farm_id,
+ farm_display_name: detail.listing.farm_display_name.clone(),
+ title: detail.listing.title.clone(),
+ quantity: detail.selected_quantity,
+ unit_price: detail.listing.price.clone(),
+ line_total_minor_units: detail
+ .listing
+ .price
+ .amount_minor_units
+ .checked_mul(detail.selected_quantity)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart line total overflow",
+ })?,
+ fulfillment_summary: detail
+ .listing
+ .next_fulfillment_window_label
+ .clone()
+ .unwrap_or_else(|| detail.listing.availability.label.clone()),
+ })
+}
+
+fn refresh_buyer_cart_totals(cart: &mut BuyerCartProjection) -> Result<(), AppSqliteError> {
+ if cart.lines.is_empty() {
+ cart.subtotal_minor_units = None;
+ cart.currency_code = None;
+ cart.replace_confirmation = None;
+ return Ok(());
+ }
+
+ let currency_code = cart.lines[0].unit_price.currency_code.clone();
+ let subtotal_minor_units = cart.lines.iter().try_fold(0u32, |subtotal, line| {
+ if line.unit_price.currency_code != currency_code {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer cart currency mismatch",
+ });
+ }
+
+ subtotal
+ .checked_add(line.line_total_minor_units)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart subtotal overflow",
+ })
+ })?;
+
+ cart.subtotal_minor_units = Some(subtotal_minor_units);
+ cart.currency_code = Some(currency_code);
+
+ Ok(())
+}
+
fn fallback_farm_profile_for_projection(
farm_id: FarmId,
farm_setup_projection: &FarmSetupProjection,
@@ -2724,6 +3043,267 @@ mod tests {
}
#[test]
+ fn runtime_personal_product_detail_adds_to_cart_and_routes_into_cart() {
+ let runtime = memory_runtime();
+ let (account_id, farm_id) = provision_ready_farmer_account(&runtime);
+ assert!(
+ runtime
+ .select_active_surface(ActiveSurface::Personal)
+ .expect("surface should switch into marketplace")
+ );
+ let fulfillment_window_id = seed_buyer_marketplace_support(
+ &runtime,
+ account_id.as_str(),
+ farm_id,
+ "North field farm",
+ "Friday pickup",
+ );
+ let product_id = seed_product(
+ &runtime,
+ farm_id,
+ "Salad mix",
+ "Spring blend",
+ "published",
+ Some(8),
+ "2026-04-20T09:00: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 = '{product_id}'"
+ ))
+ .expect("buyer detail product should attach a fulfillment window");
+
+ assert!(
+ runtime
+ .open_personal_product_detail(PersonalSection::Browse, product_id)
+ .expect("buyer detail should open")
+ );
+ assert!(runtime.increase_personal_product_quantity(PersonalSection::Browse));
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("buyer product should add to cart")
+ );
+
+ let summary = runtime.summary();
+ assert_eq!(
+ summary.shell_projection.selected_section,
+ ShellSection::Personal(PersonalSection::Cart)
+ );
+ assert_eq!(summary.personal_projection.cart.cart.lines.len(), 1);
+ assert_eq!(
+ summary.personal_projection.cart.cart.lines[0].title,
+ "Salad mix"
+ );
+ assert_eq!(summary.personal_projection.cart.cart.lines[0].quantity, 2);
+ assert_eq!(
+ summary.personal_projection.cart.cart.subtotal_minor_units,
+ Some(1200)
+ );
+ assert_eq!(
+ summary
+ .personal_projection
+ .cart
+ .cart
+ .farm_display_name
+ .as_deref(),
+ Some("North field farm")
+ );
+ assert!(
+ summary
+ .personal_projection
+ .cart
+ .cart
+ .replace_confirmation
+ .is_none()
+ );
+ assert_eq!(
+ summary
+ .personal_projection
+ .browse
+ .detail
+ .as_ref()
+ .expect("buyer detail should persist on browse")
+ .selected_quantity,
+ 2
+ );
+ }
+
+ #[test]
+ fn runtime_cross_farm_buyer_add_requires_replace_confirmation() {
+ let runtime = memory_runtime();
+ let (account_id, farm_id) = provision_ready_farmer_account(&runtime);
+ assert!(
+ runtime
+ .select_active_surface(ActiveSurface::Personal)
+ .expect("surface should switch into marketplace")
+ );
+ let first_window_id = seed_buyer_marketplace_support(
+ &runtime,
+ account_id.as_str(),
+ farm_id,
+ "North field farm",
+ "Friday pickup",
+ );
+ let first_product_id = seed_product(
+ &runtime,
+ farm_id,
+ "Salad mix",
+ "Spring blend",
+ "published",
+ Some(8),
+ "2026-04-20T09:00:00Z",
+ );
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .execute_batch(&format!(
+ "update products
+ set availability_window_id = '{first_window_id}'
+ where id = '{first_product_id}'"
+ ))
+ .expect("first product should attach a fulfillment window");
+ assert!(
+ runtime
+ .open_personal_product_detail(PersonalSection::Browse, first_product_id)
+ .expect("first buyer detail should open")
+ );
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("first buyer product should add to cart")
+ );
+
+ let other_farm_id = FarmId::new();
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_summary(&FarmSummary {
+ farm_id: other_farm_id,
+ display_name: "Willow Farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ })
+ .expect("other farm summary should save");
+ let second_window_id = seed_buyer_marketplace_support(
+ &runtime,
+ "acct_other_farmer",
+ other_farm_id,
+ "Willow Farm",
+ "Saturday pickup",
+ );
+ let second_product_id = seed_product(
+ &runtime,
+ other_farm_id,
+ "Pea shoots",
+ "Tray-grown",
+ "published",
+ Some(5),
+ "2026-04-20T10:00:00Z",
+ );
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .execute_batch(&format!(
+ "update products
+ set availability_window_id = '{second_window_id}'
+ where id = '{second_product_id}'"
+ ))
+ .expect("second product should attach a fulfillment window");
+
+ assert!(
+ runtime
+ .open_personal_product_detail(PersonalSection::Browse, second_product_id)
+ .expect("second buyer detail should open")
+ );
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("cross-farm add should require confirmation")
+ );
+
+ let confirmation_summary = runtime.summary();
+ assert_eq!(
+ confirmation_summary.shell_projection.selected_section,
+ ShellSection::Personal(PersonalSection::Browse)
+ );
+ assert_eq!(
+ confirmation_summary
+ .personal_projection
+ .cart
+ .cart
+ .lines
+ .len(),
+ 1
+ );
+ assert_eq!(
+ confirmation_summary.personal_projection.cart.cart.lines[0].title,
+ "Salad mix"
+ );
+ assert_eq!(
+ confirmation_summary
+ .personal_projection
+ .cart
+ .cart
+ .replace_confirmation
+ .as_ref()
+ .expect("replace confirmation should exist")
+ .incoming_farm_display_name,
+ "Willow Farm"
+ );
+
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, true)
+ .expect("confirmed cross-farm add should replace the cart")
+ );
+ let replaced_summary = runtime.summary();
+ assert_eq!(
+ replaced_summary.shell_projection.selected_section,
+ ShellSection::Personal(PersonalSection::Cart)
+ );
+ assert_eq!(
+ replaced_summary.personal_projection.cart.cart.lines.len(),
+ 1
+ );
+ assert_eq!(
+ replaced_summary.personal_projection.cart.cart.lines[0].title,
+ "Pea shoots"
+ );
+ assert_eq!(
+ replaced_summary
+ .personal_projection
+ .cart
+ .cart
+ .farm_display_name
+ .as_deref(),
+ Some("Willow Farm")
+ );
+ assert!(
+ replaced_summary
+ .personal_projection
+ .cart
+ .cart
+ .replace_confirmation
+ .is_none()
+ );
+ }
+
+ #[test]
fn runtime_products_queries_refresh_the_repository_backed_projection() {
let runtime = memory_runtime();
@@ -4399,6 +4979,104 @@ mod tests {
product_id
}
+ fn seed_buyer_marketplace_support(
+ runtime: &DesktopAppRuntime,
+ account_id: &str,
+ farm_id: FarmId,
+ farm_display_name: &str,
+ fulfillment_label: &str,
+ ) -> FulfillmentWindowId {
+ 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}',
+ '{fulfillment_label}',
+ '2099-04-17T18:00:00Z'
+ );
+ insert into account_farm_setups (
+ account_id,
+ farm_name,
+ location_or_service_area,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ saved_farm_id,
+ saved_farm_display_name,
+ saved_farm_readiness,
+ updated_at
+ ) values (
+ '{account_id}',
+ '{farm_display_name}',
+ 'County Road',
+ 1,
+ 0,
+ 0,
+ '{farm_id}',
+ '{farm_display_name}',
+ 'ready',
+ '2026-04-20T08:00:00Z'
+ )
+ on conflict(account_id) do update set
+ farm_name = excluded.farm_name,
+ location_or_service_area = excluded.location_or_service_area,
+ pickup_enabled = excluded.pickup_enabled,
+ delivery_enabled = excluded.delivery_enabled,
+ shipping_enabled = excluded.shipping_enabled,
+ saved_farm_id = excluded.saved_farm_id,
+ saved_farm_display_name = excluded.saved_farm_display_name,
+ saved_farm_readiness = excluded.saved_farm_readiness,
+ updated_at = excluded.updated_at;"
+ );
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .execute_batch(&sql)
+ .expect("buyer marketplace support should seed");
+
+ fulfillment_window_id
+ }
+
fn provision_ready_farmer_account(runtime: &DesktopAppRuntime) -> (String, FarmId) {
assert!(
runtime
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -12,6 +12,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
" from {farm}",
"${dollars}.{cents:02} / {}",
", ",
+ "+",
+ "-",
"0",
"1111111111111111111111111111111111111111111111111111111111111111",
"14",
@@ -33,10 +35,21 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"account-log-out",
"account-more",
"buyer",
+ "buyer-detail-add-to-cart",
+ "buyer-detail-back",
+ "buyer-detail-confirm-replace",
+ "buyer-detail-keep-current",
+ "buyer-detail-quantity-decrease",
+ "buyer-detail-quantity-increase",
+ "buyer-listing-open",
+ "buyer.add_to_cart_failed",
+ "buyer.detail_open_failed",
"bunker uri",
"bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example",
"buyer.fulfillment_filter_update_failed",
"buyer.search_query_update_failed",
+ "failed to add buyer product to cart",
+ "failed to open buyer product detail",
"failed to update buyer fulfillment filter",
"failed to update buyer search query",
"failed to add relay `{relay_url}`: {error}",
@@ -190,6 +203,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"startup-title-starting",
"wss://relay.radroots.example",
"{} items are ready in your cart{}.",
+ "{} {} {}.",
"{} local orders are already available on this device.",
"{quantity} {unit_label}",
"{} {}",
@@ -253,6 +267,13 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::PersonalSearchPlaceholderBody",
"AppTextKey::PersonalCartPlaceholderBody",
"AppTextKey::PersonalOrdersPlaceholderBody",
+ "AppTextKey::PersonalDetailBackAction",
+ "AppTextKey::PersonalDetailQuantityLabel",
+ "AppTextKey::PersonalDetailAddToCartAction",
+ "AppTextKey::PersonalDetailReplaceCartTitle",
+ "AppTextKey::PersonalDetailReplaceCartBody",
+ "AppTextKey::PersonalDetailReplaceCartAction",
+ "AppTextKey::PersonalDetailKeepCurrentCartAction",
"AppTextKey::HomeTodayOpenInOrdersAction",
"AppTextKey::HomeTodayOpenInPackDayAction",
"AppTextKey::OrdersTitle",
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -11,17 +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, 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,
+ AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCartReplaceConfirmationProjection,
+ BuyerListingRow, BuyerProductDetailProjection, 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,
@@ -35,7 +35,7 @@ use radroots_app_state::{
};
use radroots_app_ui::{
APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec,
- AppSegmentButtonIconSpec as IconSegmentButtonSpec, LabelValueRow,
+ AppSegmentButtonIconSpec as IconSegmentButtonSpec, LabelValueRow, app_button_card,
app_button_choice as choice_button, app_button_compact as action_button_compact,
app_button_icon as action_icon_button, app_button_list_row as list_row_button,
app_button_primary as action_button_primary,
@@ -1034,6 +1034,84 @@ impl HomeView {
}
}
+ fn open_personal_product_detail(
+ &mut self,
+ section: PersonalSection,
+ product_id: ProductId,
+ cx: &mut Context<Self>,
+ ) {
+ match self
+ .runtime
+ .open_personal_product_detail(section, product_id)
+ {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "buyer",
+ event = "buyer.detail_open_failed",
+ error = %runtime_error,
+ "failed to open buyer product detail"
+ );
+ }
+ }
+ }
+
+ fn close_personal_product_detail(&mut self, section: PersonalSection, cx: &mut Context<Self>) {
+ if self.runtime.close_personal_product_detail(section) {
+ cx.notify();
+ }
+ }
+
+ fn increase_personal_product_quantity(
+ &mut self,
+ section: PersonalSection,
+ cx: &mut Context<Self>,
+ ) {
+ if self.runtime.increase_personal_product_quantity(section) {
+ cx.notify();
+ }
+ }
+
+ fn decrease_personal_product_quantity(
+ &mut self,
+ section: PersonalSection,
+ cx: &mut Context<Self>,
+ ) {
+ if self.runtime.decrease_personal_product_quantity(section) {
+ cx.notify();
+ }
+ }
+
+ fn add_personal_product_to_cart(
+ &mut self,
+ section: PersonalSection,
+ replace_existing: bool,
+ cx: &mut Context<Self>,
+ ) {
+ match self
+ .runtime
+ .add_personal_product_to_cart(section, replace_existing)
+ {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "buyer",
+ event = "buyer.add_to_cart_failed",
+ error = %runtime_error,
+ "failed to add buyer product to cart"
+ );
+ }
+ }
+ }
+
+ fn clear_personal_cart_replace_confirmation(&mut self, cx: &mut Context<Self>) {
+ if self.runtime.clear_personal_cart_replace_confirmation() {
+ cx.notify();
+ }
+ }
+
fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) {
match self.runtime.select_products_filter(filter) {
Ok(true) => {
@@ -1833,7 +1911,9 @@ impl HomeView {
) -> AnyElement {
let selected_personal_section = selected_personal_section(runtime);
let main_content = match selected_personal_section {
- PersonalSection::Browse => self.render_buyer_browse_content(runtime).into_any_element(),
+ PersonalSection::Browse => self
+ .render_buyer_browse_content(runtime, cx)
+ .into_any_element(),
PersonalSection::Search => self
.render_buyer_search_content(runtime, cx)
.into_any_element(),
@@ -1882,8 +1962,18 @@ impl HomeView {
.into_any_element()
}
- fn render_buyer_browse_content(&mut self, runtime: &DesktopAppRuntimeSummary) -> AnyElement {
+ fn render_buyer_browse_content(
+ &mut self,
+ runtime: &DesktopAppRuntimeSummary,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
let listings = &runtime.personal_projection.browse.listings.rows;
+ let selected_product_id = runtime
+ .personal_projection
+ .browse
+ .detail
+ .as_ref()
+ .map(|detail| detail.listing.product_id);
app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
.w_full()
@@ -1900,7 +1990,62 @@ impl HomeView {
)
.into_any_element()
} else {
- buyer_listings_feed(listings).into_any_element()
+ app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
+ .w_full()
+ .when_some(
+ runtime.personal_projection.browse.detail.as_ref(),
+ |this, detail| {
+ this.child(buyer_product_detail_card(
+ detail,
+ runtime
+ .personal_projection
+ .cart
+ .cart
+ .replace_confirmation
+ .as_ref(),
+ cx.listener(|this, _, _, cx| {
+ this.close_personal_product_detail(PersonalSection::Browse, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.decrease_personal_product_quantity(
+ PersonalSection::Browse,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.increase_personal_product_quantity(
+ PersonalSection::Browse,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.add_personal_product_to_cart(
+ PersonalSection::Browse,
+ false,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.add_personal_product_to_cart(
+ PersonalSection::Browse,
+ true,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.clear_personal_cart_replace_confirmation(cx)
+ }),
+ cx,
+ ))
+ },
+ )
+ .child(buyer_listings_feed(
+ PersonalSection::Browse,
+ listings,
+ selected_product_id,
+ cx,
+ ))
+ .into_any_element()
})
.into_any_element()
}
@@ -1912,6 +2057,12 @@ impl HomeView {
) -> AnyElement {
let query = &runtime.personal_projection.search.query;
let listings = &runtime.personal_projection.search.listings.rows;
+ let selected_product_id = runtime
+ .personal_projection
+ .search
+ .detail
+ .as_ref()
+ .map(|detail| detail.listing.product_id);
app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
.w_full()
@@ -2016,7 +2167,62 @@ impl HomeView {
)
.into_any_element()
} else {
- buyer_listings_feed(listings).into_any_element()
+ app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
+ .w_full()
+ .when_some(
+ runtime.personal_projection.search.detail.as_ref(),
+ |this, detail| {
+ this.child(buyer_product_detail_card(
+ detail,
+ runtime
+ .personal_projection
+ .cart
+ .cart
+ .replace_confirmation
+ .as_ref(),
+ cx.listener(|this, _, _, cx| {
+ this.close_personal_product_detail(PersonalSection::Search, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.decrease_personal_product_quantity(
+ PersonalSection::Search,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.increase_personal_product_quantity(
+ PersonalSection::Search,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.add_personal_product_to_cart(
+ PersonalSection::Search,
+ false,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.add_personal_product_to_cart(
+ PersonalSection::Search,
+ true,
+ cx,
+ )
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.clear_personal_cart_replace_confirmation(cx)
+ }),
+ cx,
+ ))
+ },
+ )
+ .child(buyer_listings_feed(
+ PersonalSection::Search,
+ listings,
+ selected_product_id,
+ cx,
+ ))
+ .into_any_element()
})
.into_any_element()
}
@@ -5294,24 +5500,55 @@ fn buyer_workspace_title_block(title_key: AppTextKey, body_key: AppTextKey) -> i
)
}
-fn buyer_listings_feed(rows: &[BuyerListingRow]) -> impl IntoElement {
+fn buyer_listings_feed(
+ section: PersonalSection,
+ rows: &[BuyerListingRow],
+ selected_product_id: Option<ProductId>,
+ cx: &mut Context<HomeView>,
+) -> impl IntoElement {
app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
.w_full()
- .children(rows.iter().map(buyer_listing_card).collect::<Vec<_>>())
+ .children(
+ rows.iter()
+ .enumerate()
+ .map(|(index, row)| {
+ buyer_listing_card(
+ index,
+ section,
+ row,
+ selected_product_id == Some(row.product_id),
+ cx,
+ )
+ })
+ .collect::<Vec<_>>(),
+ )
}
-fn buyer_listing_card(row: &BuyerListingRow) -> AnyElement {
+fn buyer_listing_card(
+ index: usize,
+ section: PersonalSection,
+ row: &BuyerListingRow,
+ is_selected: bool,
+ cx: &mut Context<HomeView>,
+) -> AnyElement {
let subtitle = row
.subtitle
.as_deref()
.map(str::trim)
.filter(|subtitle| !subtitle.is_empty())
.map(str::to_owned);
-
- app_surface_card(
+ app_button_card(
+ ("buyer-listing-open", index),
+ is_selected,
+ cx.listener({
+ let product_id = row.product_id;
+ move |this, _, _, cx| this.open_personal_product_detail(section, product_id, cx)
+ }),
+ cx,
div()
.w_full()
.min_w_0()
+ .p(px(APP_UI_THEME.shells.home_card_padding_px))
.flex()
.flex_col()
.gap(px(APP_UI_THEME.shells.home_stack_gap_px))
@@ -5441,6 +5678,147 @@ fn buyer_listing_price_text(price: &ProductPricePresentation) -> String {
format!("${dollars}.{cents:02} / {}", price.unit_label)
}
+fn buyer_product_detail_card(
+ detail: &BuyerProductDetailProjection,
+ replace_confirmation: Option<&BuyerCartReplaceConfirmationProjection>,
+ on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_decrease_quantity: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_increase_quantity: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_add_to_cart: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_confirm_replace: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_keep_current_cart: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ app_surface_card(
+ app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
+ .w_full()
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_start()
+ .justify_between()
+ .gap(px(APP_UI_THEME.shells.home_stack_gap_px))
+ .child(
+ app_stack_v(4.0)
+ .flex_1()
+ .min_w_0()
+ .child(app_text_value(product_display_title(
+ detail.listing.title.as_str(),
+ )))
+ .child(settings_badge_text(
+ detail.listing.farm_display_name.clone(),
+ )),
+ )
+ .child(text_button(
+ "buyer-detail-back",
+ app_shared_text(AppTextKey::PersonalDetailBackAction),
+ on_close,
+ cx,
+ )),
+ )
+ .when_some(
+ detail
+ .detail_text
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned),
+ |this, detail_text| this.child(home_body_text(detail_text)),
+ )
+ .child(
+ app_cluster(APP_UI_THEME.foundation.spacing.small_px)
+ .w_full()
+ .child(buyer_listing_chip(buyer_listing_price_text(
+ &detail.listing.price,
+ )))
+ .child(buyer_listing_chip(buyer_listing_next_window_text(
+ &detail.listing,
+ )))
+ .child(buyer_listing_chip(buyer_listing_fulfillment_methods_text(
+ &detail.listing.fulfillment_methods,
+ )))
+ .child(buyer_listing_chip(
+ buyer_listing_stock_or_availability_text(&detail.listing),
+ )),
+ )
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .justify_between()
+ .gap(px(APP_UI_THEME.shells.home_stack_gap_px))
+ .child(app_text_label(app_shared_text(
+ AppTextKey::PersonalDetailQuantityLabel,
+ )))
+ .child(
+ app_stack_h(APP_UI_THEME.foundation.spacing.small_px)
+ .child(action_button_compact(
+ "buyer-detail-quantity-decrease",
+ SharedString::from("-"),
+ on_decrease_quantity,
+ cx,
+ ))
+ .child(
+ div()
+ .min_w(px(36.0))
+ .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(detail.selected_quantity.to_string()),
+ )
+ .child(action_button_compact(
+ "buyer-detail-quantity-increase",
+ SharedString::from("+"),
+ on_increase_quantity,
+ cx,
+ )),
+ ),
+ )
+ .when_some(replace_confirmation, |this, replace_confirmation| {
+ this.child(app_surface_panel(
+ app_stack_v(APP_UI_THEME.foundation.spacing.small_px)
+ .w_full()
+ .p(px(APP_UI_THEME.shells.home_card_padding_px))
+ .child(app_text_label(app_shared_text(
+ AppTextKey::PersonalDetailReplaceCartTitle,
+ )))
+ .child(home_body_text(format!(
+ "{} {} {}.",
+ replace_confirmation.current_farm_display_name,
+ app_shared_text(AppTextKey::PersonalDetailReplaceCartBody),
+ replace_confirmation.incoming_farm_display_name,
+ )))
+ .child(
+ app_cluster(APP_UI_THEME.foundation.spacing.small_px)
+ .w_full()
+ .child(action_button_primary(
+ "buyer-detail-confirm-replace",
+ app_shared_text(AppTextKey::PersonalDetailReplaceCartAction),
+ on_confirm_replace,
+ cx,
+ ))
+ .child(action_button_compact(
+ "buyer-detail-keep-current",
+ app_shared_text(
+ AppTextKey::PersonalDetailKeepCurrentCartAction,
+ ),
+ on_keep_current_cart,
+ cx,
+ )),
+ ),
+ ))
+ })
+ .child(action_button_primary(
+ "buyer-detail-add-to-cart",
+ app_shared_text(AppTextKey::PersonalDetailAddToCartAction),
+ on_add_to_cart,
+ cx,
+ )),
+ )
+}
+
fn buyer_surface_placeholder(
title_key: AppTextKey,
body_key: AppTextKey,
diff --git a/crates/shared/core/src/lib.rs b/crates/shared/core/src/lib.rs
@@ -18,10 +18,10 @@ pub use paths::{
SHARED_IDENTITIES_NAMESPACE_VALUE, SHARED_IDENTITY_FILE_NAME,
};
pub use runtime::{
- APP_HOST_PLATFORM, APP_ID, APP_NAME, APP_PLATFORM_RUNTIME, APP_PROJECTION_SOURCE,
- APP_RUNTIME_MODE_ENV, APP_DEFAULT_NOSTR_RELAY_URL_ENV, APP_LOCAL_LOG_ROOT_ENV,
- APP_RUNTIME_ORIGIN, AppBuildIdentity, AppCoreRuntimeMetadata, AppHostRuntimeMetadata,
- AppRuntimeCapture, AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeMode,
- AppRuntimeSnapshot, runtime_mode_label,
+ APP_DEFAULT_NOSTR_RELAY_URL_ENV, APP_HOST_PLATFORM, APP_ID, APP_LOCAL_LOG_ROOT_ENV, APP_NAME,
+ APP_PLATFORM_RUNTIME, APP_PROJECTION_SOURCE, APP_RUNTIME_MODE_ENV, APP_RUNTIME_ORIGIN,
+ AppBuildIdentity, AppCoreRuntimeMetadata, AppHostRuntimeMetadata, AppRuntimeCapture,
+ AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeMode, AppRuntimeSnapshot,
+ runtime_mode_label,
};
pub use startup::{AppStartupEvent, AppStartupEventMetadata, launch_startup_event};
diff --git a/crates/shared/core/src/runtime.rs b/crates/shared/core/src/runtime.rs
@@ -104,10 +104,8 @@ impl AppRuntimeConfig {
where
F: FnMut(&str) -> Option<String>,
{
- let runtime_mode = parse_config_runtime_mode(&require_env_value(
- &mut read_env,
- APP_RUNTIME_MODE_ENV,
- )?)?;
+ let runtime_mode =
+ parse_config_runtime_mode(&require_env_value(&mut read_env, APP_RUNTIME_MODE_ENV)?)?;
let default_nostr_relay_url =
require_env_value(&mut read_env, APP_DEFAULT_NOSTR_RELAY_URL_ENV)?;
let local_log_root = read_env(APP_LOCAL_LOG_ROOT_ENV)
@@ -146,7 +144,11 @@ impl AppRuntimeSnapshot {
}
pub fn capture_for_mode(build: AppBuildIdentity, runtime_mode: AppRuntimeMode) -> Self {
- Self::from_capture(build, runtime_mode, AppRuntimeCapture::current(&runtime_mode))
+ Self::from_capture(
+ build,
+ runtime_mode,
+ AppRuntimeCapture::current(&runtime_mode),
+ )
}
pub fn from_capture(
@@ -341,12 +343,10 @@ mod tests {
#[test]
fn runtime_config_requires_explicit_runtime_mode_env() {
- let env = BTreeMap::from([
- (
- APP_DEFAULT_NOSTR_RELAY_URL_ENV,
- "ws://127.0.0.1:8080".to_owned(),
- ),
- ]);
+ let env = BTreeMap::from([(
+ APP_DEFAULT_NOSTR_RELAY_URL_ENV,
+ "ws://127.0.0.1:8080".to_owned(),
+ )]);
let error = AppRuntimeConfig::from_env_with(
|name| env.get(name).cloned(),
Some(PathBuf::from("/tmp/default-logs")),
@@ -451,10 +451,7 @@ mod tests {
fn runtime_config_rejects_empty_required_fields() {
let env = BTreeMap::from([
(APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()),
- (
- APP_DEFAULT_NOSTR_RELAY_URL_ENV,
- "".to_owned(),
- ),
+ (APP_DEFAULT_NOSTR_RELAY_URL_ENV, "".to_owned()),
]);
let error = AppRuntimeConfig::from_env_with(
|name| env.get(name).cloned(),
@@ -473,9 +470,7 @@ mod tests {
#[test]
fn runtime_config_rejects_missing_default_nostr_relay_url() {
- let env = BTreeMap::from([
- (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()),
- ]);
+ let env = BTreeMap::from([(APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned())]);
let error = AppRuntimeConfig::from_env_with(
|name| env.get(name).cloned(),
Some(PathBuf::from("/tmp/default-logs")),
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -110,6 +110,13 @@ define_app_text_keys! {
PersonalSearchPlaceholderBody => "personal.search.placeholder.body",
PersonalCartPlaceholderBody => "personal.cart.placeholder.body",
PersonalOrdersPlaceholderBody => "personal.orders.placeholder.body",
+ PersonalDetailBackAction => "personal.detail.back_action",
+ PersonalDetailQuantityLabel => "personal.detail.quantity.label",
+ PersonalDetailAddToCartAction => "personal.detail.add_to_cart.action",
+ PersonalDetailReplaceCartTitle => "personal.detail.replace_cart.title",
+ PersonalDetailReplaceCartBody => "personal.detail.replace_cart.body",
+ PersonalDetailReplaceCartAction => "personal.detail.replace_cart.action",
+ PersonalDetailKeepCurrentCartAction => "personal.detail.keep_current_cart.action",
OrdersTitle => "orders.title",
OrdersFiltersTitle => "orders.filters.title",
OrdersSummaryTotal => "orders.summary.total",
diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs
@@ -192,6 +192,23 @@ mod tests {
}
#[test]
+ fn english_marketplace_detail_copy_matches_the_buyer_detail_contract() {
+ assert_eq!(app_text(AppTextKey::PersonalDetailBackAction), "Back");
+ assert_eq!(
+ app_text(AppTextKey::PersonalDetailQuantityLabel),
+ "Quantity"
+ );
+ assert_eq!(
+ app_text(AppTextKey::PersonalDetailAddToCartAction),
+ "Add to cart"
+ );
+ assert_eq!(
+ app_text(AppTextKey::PersonalDetailReplaceCartAction),
+ "Replace cart"
+ );
+ }
+
+ #[test]
fn english_pack_day_copy_matches_the_contextual_execution_contract() {
assert_eq!(app_text(AppTextKey::PackDayTitle), "Pack day");
assert_eq!(
diff --git a/crates/shared/ui/src/lib.rs b/crates/shared/ui/src/lib.rs
@@ -6,7 +6,7 @@ mod theme;
pub use primitives::{
AppCheckboxFieldSpec, AppFormFieldSpec, AppSegmentButtonIconSpec, LabelValueRow,
- app_button_choice, app_button_compact, app_button_icon, app_button_list_row,
+ app_button_card, app_button_choice, app_button_compact, app_button_icon, app_button_list_row,
app_button_primary, app_button_primary_disabled, app_button_secondary, app_button_text,
app_checkbox_field, app_cluster, app_detail_row, app_divider, app_form_field,
app_form_input_text, app_form_section, app_heading_section, app_heading_view, app_input_text,
diff --git a/crates/shared/ui/src/primitives.rs b/crates/shared/ui/src/primitives.rs
@@ -771,6 +771,44 @@ pub fn app_button_list_row(
)
}
+pub fn app_button_card(
+ id: impl Into<ElementId>,
+ is_selected: bool,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+ content: impl IntoElement,
+) -> impl IntoElement {
+ let selected_background = rgb(APP_UI_THEME.foundation.surfaces.window_background);
+
+ Button::new(id)
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(rgb(APP_UI_THEME.foundation.surfaces.card_background).into())
+ .foreground(rgb(APP_UI_THEME.foundation.text.primary).into())
+ .border(transparent_black())
+ .hover(selected_background.into())
+ .active(selected_background.into()),
+ )
+ .rounded(ButtonRounded::Size(px(APP_UI_THEME
+ .foundation
+ .radii
+ .medium_px)))
+ .w_full()
+ .on_click(on_click)
+ .child(
+ div()
+ .w_full()
+ .min_w_0()
+ .bg(rgb(if is_selected {
+ APP_UI_THEME.foundation.surfaces.window_background
+ } else {
+ APP_UI_THEME.foundation.surfaces.card_background
+ }))
+ .rounded(px(APP_UI_THEME.foundation.radii.medium_px))
+ .child(content),
+ )
+}
+
fn app_button_base(
id: impl Into<ElementId>,
variant: AppButtonVariant,
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -89,6 +89,13 @@
"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.",
"personal.orders.placeholder.body": "Placed orders will appear here on this device.",
+ "personal.detail.back_action": "Back",
+ "personal.detail.quantity.label": "Quantity",
+ "personal.detail.add_to_cart.action": "Add to cart",
+ "personal.detail.replace_cart.title": "Replace current cart?",
+ "personal.detail.replace_cart.body": "is already in your cart. Replace it with items from",
+ "personal.detail.replace_cart.action": "Replace cart",
+ "personal.detail.keep_current_cart.action": "Keep current cart",
"orders.title": "Orders",
"orders.filters.title": "View",
"orders.summary.total": "Total orders",