app

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

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:
Mcrates/launchers/desktop/src/runtime.rs | 692++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/launchers/desktop/src/source_guards.rs | 21+++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 420+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/core/src/lib.rs | 10+++++-----
Mcrates/shared/core/src/runtime.rs | 31+++++++++++++------------------
Mcrates/shared/i18n/src/keys.rs | 7+++++++
Mcrates/shared/i18n/src/lib.rs | 17+++++++++++++++++
Mcrates/shared/ui/src/lib.rs | 2+-
Mcrates/shared/ui/src/primitives.rs | 38++++++++++++++++++++++++++++++++++++++
Mi18n/locales/en/messages.json | 7+++++++
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",