app

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

commit 6411af3a08a94db6e6db5b5b5a2a7d6e98a61e3c
parent a90eb525b0b474b34b7a7fc5da6dd75985e19b08
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 16:04:14 +0000

feat: add buyer marketplace shell contracts

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 2+-
Mcrates/launchers/desktop/src/window.rs | 4+++-
Mcrates/shared/models/src/lib.rs | 456++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/state/src/lib.rs | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
4 files changed, 575 insertions(+), 34 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -1485,7 +1485,7 @@ impl DesktopAppRuntimeState { !self.has_saved_farm() || !self.has_pack_day_context() } ShellSection::Farmer(FarmerSection::Farm) => true, - ShellSection::Home | ShellSection::Settings(_) => false, + ShellSection::Home | ShellSection::Personal(_) | ShellSection::Settings(_) => false, }; should_reset_to_today diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -5405,7 +5405,9 @@ fn home_sidebar_navigation_sections( fn selected_farmer_section(runtime: &DesktopAppRuntimeSummary) -> FarmerSection { match runtime.shell_projection.selected_section { ShellSection::Farmer(section) => section, - ShellSection::Home | ShellSection::Settings(_) => FarmerSection::Today, + ShellSection::Home | ShellSection::Personal(_) | ShellSection::Settings(_) => { + FarmerSection::Today + } } } diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -47,6 +47,27 @@ impl FarmerSection { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +pub enum PersonalSection { + #[default] + Browse, + Search, + Cart, + Orders, +} + +impl PersonalSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Browse => "personal.browse", + Self::Search => "personal.search", + Self::Cart => "personal.cart", + Self::Orders => "personal.orders", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum SettingsSection { #[default] Account, @@ -91,6 +112,7 @@ impl SettingsPreference { pub enum ShellSection { #[default] Home, + Personal(PersonalSection), Farmer(FarmerSection), Settings(SettingsSection), } @@ -99,13 +121,14 @@ impl ShellSection { pub const fn surface(self) -> Option<ActiveSurface> { match self { Self::Home | Self::Settings(_) => None, + Self::Personal(_) => Some(ActiveSurface::Personal), Self::Farmer(_) => Some(ActiveSurface::Farmer), } } pub const fn default_for_surface(surface: ActiveSurface) -> Self { match surface { - ActiveSurface::Personal => Self::Home, + ActiveSurface::Personal => Self::Personal(PersonalSection::Browse), ActiveSurface::Farmer => Self::Farmer(FarmerSection::Today), } } @@ -113,6 +136,7 @@ impl ShellSection { pub const fn storage_key(self) -> &'static str { match self { Self::Home => "home", + Self::Personal(section) => section.storage_key(), Self::Farmer(section) => section.storage_key(), Self::Settings(section) => section.storage_key(), } @@ -136,6 +160,10 @@ impl FromStr for ShellSection { fn from_str(value: &str) -> Result<Self, Self::Err> { match value { "home" => Ok(Self::Home), + "personal.browse" => Ok(Self::Personal(PersonalSection::Browse)), + "personal.search" => Ok(Self::Personal(PersonalSection::Search)), + "personal.cart" => Ok(Self::Personal(PersonalSection::Cart)), + "personal.orders" => Ok(Self::Personal(PersonalSection::Orders)), "farmer.today" => Ok(Self::Farmer(FarmerSection::Today)), "farmer.products" => Ok(Self::Farmer(FarmerSection::Products)), "farmer.orders" => Ok(Self::Farmer(FarmerSection::Orders)), @@ -551,6 +579,64 @@ pub struct LoggedOutStartupProjection { pub signer_entry: StartupSignerEntryProjection, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PersonalEntryState { + Blocked, + #[default] + Guest, + SignedIn, +} + +impl PersonalEntryState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Blocked => "blocked", + Self::Guest => "guest", + Self::SignedIn => "signed_in", + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PersonalEntryProjection { + pub state: PersonalEntryState, + pub selected_account: Option<SelectedAccountProjection>, + pub can_enter_farmer_workspace: bool, +} + +impl PersonalEntryProjection { + pub fn blocked(selected_account: Option<SelectedAccountProjection>) -> Self { + let can_enter_farmer_workspace = selected_account + .as_ref() + .is_some_and(|account| account.farmer_activation.is_active()); + + Self { + state: PersonalEntryState::Blocked, + selected_account, + can_enter_farmer_workspace, + } + } + + pub const fn guest() -> Self { + Self { + state: PersonalEntryState::Guest, + selected_account: None, + can_enter_farmer_workspace: false, + } + } + + pub fn signed_in(selected_account: SelectedAccountProjection) -> Self { + let can_enter_farmer_workspace = selected_account.farmer_activation.is_active(); + + Self { + state: PersonalEntryState::SignedIn, + selected_account: Some(selected_account), + can_enter_farmer_workspace, + } + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct AppIdentityProjection { pub readiness: IdentityReadiness, @@ -626,6 +712,20 @@ impl AppIdentityProjection { pub fn settings_account(&self) -> SettingsAccountProjection { self.into() } + + pub fn personal_entry(&self) -> PersonalEntryProjection { + match self.readiness { + IdentityReadiness::MissingAccount => PersonalEntryProjection::guest(), + IdentityReadiness::Blocked(_) => { + PersonalEntryProjection::blocked(self.selected_account.clone()) + } + IdentityReadiness::Ready => self + .selected_account + .clone() + .map(PersonalEntryProjection::signed_in) + .unwrap_or_else(PersonalEntryProjection::guest), + } + } } #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] @@ -1114,6 +1214,96 @@ impl ProductEditorDraft { } } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerListingRow { + pub product_id: ProductId, + pub farm_id: FarmId, + pub farm_display_name: String, + pub title: String, + pub subtitle: Option<String>, + pub price: ProductPricePresentation, + pub availability: ProductAvailabilitySummary, + pub stock: ProductStockSummary, + pub fulfillment_methods: BTreeSet<FarmOrderMethod>, + pub next_fulfillment_window_label: Option<String>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerListingsProjection { + pub rows: Vec<BuyerListingRow>, +} + +impl BuyerListingsProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerProductDetailProjection { + pub listing: BuyerListingRow, + pub detail_text: Option<String>, + pub selected_quantity: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCartLineProjection { + pub product_id: ProductId, + pub farm_id: FarmId, + pub farm_display_name: String, + pub title: String, + pub quantity: u32, + pub unit_price: ProductPricePresentation, + pub line_total_minor_units: u32, + pub fulfillment_summary: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCartReplaceConfirmationProjection { + pub current_farm_display_name: String, + pub incoming_farm_display_name: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCartProjection { + pub farm_id: Option<FarmId>, + pub farm_display_name: Option<String>, + pub lines: Vec<BuyerCartLineProjection>, + pub subtotal_minor_units: Option<u32>, + pub currency_code: Option<String>, + pub replace_confirmation: Option<BuyerCartReplaceConfirmationProjection>, +} + +impl BuyerCartProjection { + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCheckoutDraft { + pub name: String, + pub email: String, + pub phone: String, + pub order_note: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCheckoutSummaryProjection { + pub farm_display_name: Option<String>, + pub fulfillment_summary: Option<String>, + pub line_count: u32, + pub subtotal_minor_units: Option<u32>, + pub currency_code: Option<String>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCheckoutProjection { + pub draft: BuyerCheckoutDraft, + pub summary: BuyerCheckoutSummaryProjection, + pub can_place_order: bool, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrderStatus { @@ -1136,6 +1326,40 @@ impl OrderStatus { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuyerOrderStatus { + Placed, + Scheduled, + Ready, + Completed, + Refunded, +} + +impl BuyerOrderStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Placed => "placed", + Self::Scheduled => "scheduled", + Self::Ready => "ready", + Self::Completed => "completed", + Self::Refunded => "refunded", + } + } +} + +impl From<OrderStatus> for BuyerOrderStatus { + fn from(value: OrderStatus) -> Self { + match value { + OrderStatus::NeedsAction => Self::Placed, + OrderStatus::Scheduled => Self::Scheduled, + OrderStatus::Packed => Self::Ready, + OrderStatus::Completed => Self::Completed, + OrderStatus::Refunded => Self::Refunded, + } + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrdersFilter { @@ -1244,6 +1468,39 @@ pub struct OrderDetailProjection { pub primary_action: Option<OrderPrimaryAction>, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerOrdersListRow { + pub order_id: OrderId, + pub farm_id: FarmId, + pub order_number: String, + pub farm_display_name: String, + pub fulfillment_summary: String, + pub status: BuyerOrderStatus, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerOrdersProjection { + pub rows: Vec<BuyerOrdersListRow>, +} + +impl BuyerOrdersProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerOrderDetailProjection { + pub order_id: OrderId, + pub farm_id: FarmId, + pub order_number: String, + pub farm_display_name: String, + pub fulfillment_summary: String, + pub status: BuyerOrderStatus, + pub items: Vec<OrderDetailItemRow>, + pub order_note: Option<String>, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct PackDayScreenQueryState { pub fulfillment_window_id: Option<FulfillmentWindowId>, @@ -1614,23 +1871,26 @@ mod tests { use super::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, - AppIdentityProjection, AppStartupGate, BlackoutPeriodId, FarmId, FarmOrderMethod, - FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, - FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, - FarmTimingConflict, FarmTimingConflictKind, FarmerActivationProjection, FarmerSection, - IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase, + AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection, + BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, + BuyerCheckoutSummaryProjection, BuyerListingRow, BuyerListingsProjection, + BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection, + FarmId, FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, + FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, + FarmSetupSection, FarmTimingConflict, FarmTimingConflictKind, FarmerActivationProjection, + FarmerSection, IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderListRow, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, - ParseStartupSignerSourceError, PickupLocationId, ProductAttentionState, - ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, - ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState, - ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow, - ProductsListSummary, ProductsSort, SelectedAccountProjection, SelectedSurfaceProjection, - SettingsPreference, SettingsSection, ShellSection, StartupSignerEntryProjection, - StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, TodaySummary, + ParseStartupSignerSourceError, PersonalEntryProjection, PersonalEntryState, + PersonalSection, PickupLocationId, ProductAttentionState, ProductAvailabilityState, + ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, ProductPricePresentation, + ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, + ProductsFilter, ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -1639,6 +1899,10 @@ mod tests { fn shell_section_storage_keys_are_unique_and_round_trip() { let sections = [ ShellSection::Home, + ShellSection::Personal(PersonalSection::Browse), + ShellSection::Personal(PersonalSection::Search), + ShellSection::Personal(PersonalSection::Cart), + ShellSection::Personal(PersonalSection::Orders), ShellSection::Farmer(FarmerSection::Today), ShellSection::Farmer(FarmerSection::Products), ShellSection::Farmer(FarmerSection::Orders), @@ -1664,9 +1928,13 @@ mod tests { } #[test] - fn shell_section_surface_is_explicit_only_for_farmer_routes() { + fn shell_section_surface_is_explicit_for_surface_routes_only() { assert_eq!(ShellSection::Home.surface(), None); assert_eq!( + ShellSection::Personal(PersonalSection::Browse).surface(), + Some(ActiveSurface::Personal) + ); + assert_eq!( ShellSection::Farmer(FarmerSection::Today).surface(), Some(ActiveSurface::Farmer) ); @@ -1680,7 +1948,7 @@ mod tests { fn shell_section_default_for_surface_preserves_current_farmer_entry() { assert_eq!( ShellSection::default_for_surface(ActiveSurface::Personal), - ShellSection::Home + ShellSection::Personal(PersonalSection::Browse) ); assert_eq!( ShellSection::default_for_surface(ActiveSurface::Farmer), @@ -1853,6 +2121,49 @@ mod tests { } #[test] + fn personal_entry_projection_is_derived_from_identity_truth() { + let guest_identity = AppIdentityProjection::missing(); + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_farmer".to_owned(), + npub: "npub1farmer".to_owned(), + label: Some("Field stand".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ); + let signed_in_identity = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); + let blocked_identity = AppIdentityProjection::blocked_with_selection( + IdentityBlockedReason::HostVaultUnavailable, + Vec::new(), + Some(selected_account.clone()), + ); + + assert_eq!( + guest_identity.personal_entry(), + PersonalEntryProjection::guest() + ); + assert_eq!( + guest_identity.personal_entry().state.storage_key(), + PersonalEntryState::Guest.storage_key() + ); + assert_eq!( + signed_in_identity.personal_entry(), + PersonalEntryProjection::signed_in(selected_account.clone()) + ); + assert!( + signed_in_identity + .personal_entry() + .can_enter_farmer_workspace + ); + assert_eq!( + blocked_identity.personal_entry(), + PersonalEntryProjection::blocked(Some(selected_account)) + ); + } + + #[test] fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() { assert_eq!( LoggedOutStartupProjection::default(), @@ -2096,6 +2407,19 @@ mod tests { assert_eq!(OrderStatus::Packed.storage_key(), "packed"); assert_eq!(OrderStatus::Completed.storage_key(), "completed"); assert_eq!(OrderStatus::Refunded.storage_key(), "refunded"); + assert_eq!(BuyerOrderStatus::Placed.storage_key(), "placed"); + assert_eq!(BuyerOrderStatus::Scheduled.storage_key(), "scheduled"); + assert_eq!(BuyerOrderStatus::Ready.storage_key(), "ready"); + assert_eq!(BuyerOrderStatus::Completed.storage_key(), "completed"); + assert_eq!(BuyerOrderStatus::Refunded.storage_key(), "refunded"); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::NeedsAction), + BuyerOrderStatus::Placed + ); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::Packed), + BuyerOrderStatus::Ready + ); assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction); assert_eq!(OrdersFilter::All.storage_key(), "all"); @@ -2203,6 +2527,106 @@ mod tests { } #[test] + fn buyer_marketplace_projections_hold_guest_capable_contract_data() { + let farm_id = FarmId::new(); + let product_id = super::ProductId::new(); + let order_id = super::OrderId::new(); + let listing = BuyerListingRow { + product_id, + farm_id, + farm_display_name: "Cedar Grove Farm".to_owned(), + title: "Spring salad mix".to_owned(), + subtitle: Some("Tender leaves".to_owned()), + price: ProductPricePresentation { + amount_minor_units: 650, + currency_code: "USD".to_owned(), + unit_label: "bag".to_owned(), + }, + availability: ProductAvailabilitySummary { + state: ProductAvailabilityState::Scheduled, + label: "Thursday pickup".to_owned(), + }, + stock: ProductStockSummary { + quantity: Some(8), + unit_label: Some("bag".to_owned()), + state: ProductStockState::InStock, + }, + fulfillment_methods: BTreeSet::from([FarmOrderMethod::Pickup]), + next_fulfillment_window_label: Some("Thursday pickup".to_owned()), + }; + let listings = BuyerListingsProjection { + rows: vec![listing.clone()], + }; + let cart = BuyerCartProjection { + farm_id: Some(farm_id), + farm_display_name: Some("Cedar Grove Farm".to_owned()), + lines: vec![BuyerCartLineProjection { + product_id, + farm_id, + farm_display_name: "Cedar Grove Farm".to_owned(), + title: "Spring salad mix".to_owned(), + quantity: 2, + unit_price: ProductPricePresentation { + amount_minor_units: 650, + currency_code: "USD".to_owned(), + unit_label: "bag".to_owned(), + }, + line_total_minor_units: 1300, + fulfillment_summary: "Thursday pickup".to_owned(), + }], + subtotal_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + replace_confirmation: None, + }; + let checkout = BuyerCheckoutProjection { + draft: BuyerCheckoutDraft { + name: "Casey Buyer".to_owned(), + email: "casey@example.com".to_owned(), + phone: String::new(), + order_note: "Leave by the cooler".to_owned(), + }, + summary: BuyerCheckoutSummaryProjection { + farm_display_name: Some("Cedar Grove Farm".to_owned()), + fulfillment_summary: Some("Thursday pickup".to_owned()), + line_count: 1, + subtotal_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + }, + can_place_order: true, + }; + let orders = BuyerOrdersProjection { + rows: vec![BuyerOrdersListRow { + order_id, + farm_id, + order_number: "R-2001".to_owned(), + farm_display_name: "Cedar Grove Farm".to_owned(), + fulfillment_summary: "Thursday pickup".to_owned(), + status: BuyerOrderStatus::Scheduled, + }], + }; + let order_detail = BuyerOrderDetailProjection { + order_id, + farm_id, + order_number: "R-2001".to_owned(), + farm_display_name: "Cedar Grove Farm".to_owned(), + fulfillment_summary: "Thursday pickup".to_owned(), + status: BuyerOrderStatus::Scheduled, + items: vec![OrderDetailItemRow { + title: "Spring salad mix".to_owned(), + quantity_display: "2 bags".to_owned(), + }], + order_note: Some("Leave by the cooler".to_owned()), + }; + + assert!(!listings.is_empty()); + assert!(!cart.is_empty()); + assert!(checkout.can_place_order); + assert!(!orders.is_empty()); + assert_eq!(listing.fulfillment_methods.len(), 1); + assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled); + } + + #[test] fn today_agenda_stays_on_the_compact_order_row_contract() { let today = TodayAgendaProjection { orders_needing_action: vec![OrderListRow { diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -1,14 +1,18 @@ #![forbid(unsafe_code)] +use std::collections::BTreeSet; + use radroots_app_models::{ - ActiveSurface, AppIdentityProjection, AppStartupGate, FarmReadiness, FarmReadinessBlocker, - FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, - FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, LoggedOutStartupProjection, - OrderDetailProjection, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, - PackDayProjection, PackDayScreenQueryState, ProductEditorDraft, ProductId, - ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, - SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + ActiveSurface, AppIdentityProjection, AppStartupGate, BuyerCartProjection, + BuyerCheckoutProjection, BuyerListingsProjection, BuyerOrderDetailProjection, + BuyerOrdersProjection, BuyerProductDetailProjection, FarmOrderMethod, FarmReadiness, + FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, + FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, + LoggedOutStartupProjection, OrderDetailProjection, OrdersFilter, OrdersListProjection, + OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, + ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, + ProductsSort, SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use thiserror::Error; @@ -71,6 +75,58 @@ impl SettingsShellProjection { } } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BuyerSearchScreenQueryState { + pub search_query: String, + pub fulfillment_methods: BTreeSet<FarmOrderMethod>, +} + +impl BuyerSearchScreenQueryState { + pub fn new( + search_query: impl Into<String>, + fulfillment_methods: impl IntoIterator<Item = FarmOrderMethod>, + ) -> Self { + Self { + search_query: search_query.into(), + fulfillment_methods: fulfillment_methods.into_iter().collect(), + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BuyerBrowseScreenProjection { + pub listings: BuyerListingsProjection, + pub detail: Option<BuyerProductDetailProjection>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BuyerSearchScreenProjection { + pub query: BuyerSearchScreenQueryState, + pub listings: BuyerListingsProjection, + pub detail: Option<BuyerProductDetailProjection>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BuyerCartScreenProjection { + pub cart: BuyerCartProjection, + pub checkout: BuyerCheckoutProjection, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BuyerOrdersScreenProjection { + pub list: BuyerOrdersProjection, + pub detail: Option<BuyerOrderDetailProjection>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PersonalWorkspaceProjection { + pub entry: PersonalEntryProjection, + pub browse: BuyerBrowseScreenProjection, + pub search: BuyerSearchScreenProjection, + pub cart: BuyerCartScreenProjection, + pub orders: BuyerOrdersScreenProjection, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProductsScreenQueryState { pub search_query: String, @@ -359,7 +415,10 @@ impl AppShellProjection { } } ActiveSurface::Farmer => { - if matches!(self.selected_section, ShellSection::Home) { + if matches!( + self.selected_section, + ShellSection::Home | ShellSection::Personal(_) + ) { self.selected_section = ShellSection::default_for_surface(active_surface); } } @@ -381,6 +440,7 @@ pub struct AppProjection { pub identity: AppIdentityProjection, pub startup_gate: AppStartupGate, pub logged_out_startup: LoggedOutStartupProjection, + pub personal: PersonalWorkspaceProjection, pub today: TodayAgendaProjection, pub products: ProductsScreenProjection, pub orders: OrdersScreenProjection, @@ -411,6 +471,7 @@ impl AppProjection { identity, startup_gate: AppStartupGate::default(), logged_out_startup: LoggedOutStartupProjection::default(), + personal: PersonalWorkspaceProjection::default(), today, products: ProductsScreenProjection::default(), orders: OrdersScreenProjection::default(), @@ -728,6 +789,10 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.projection.logged_out_startup } + pub fn personal_projection(&self) -> &PersonalWorkspaceProjection { + &self.projection.personal + } + pub fn products_projection(&self) -> &ProductsScreenProjection { &self.projection.products } @@ -778,6 +843,11 @@ impl<R: AppStateRepository> AppStateStore<R> { Ok(true) } + AppStateMutation::PersonalChanged => { + self.projection = next_projection; + + Ok(true) + } AppStateMutation::TodayChanged => { self.projection = next_projection; @@ -835,6 +905,11 @@ impl AppStateStore<InMemoryAppStateRepository> { true } + AppStateMutation::PersonalChanged => { + self.projection = next_projection; + + true + } AppStateMutation::TodayChanged => { self.projection = next_projection; @@ -865,6 +940,7 @@ enum AppStateMutation { ShellChanged, FarmSetupChanged, StartupChanged, + PersonalChanged, TodayChanged, ProductsChanged, OrdersChanged, @@ -1015,6 +1091,8 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateMutation::FarmSetupChanged } else if projection.logged_out_startup != before.logged_out_startup { AppStateMutation::StartupChanged + } else if projection.personal != before.personal { + AppStateMutation::PersonalChanged } else if projection.products != before.products { AppStateMutation::ProductsChanged } else if projection.orders != before.orders { @@ -1043,6 +1121,7 @@ fn sync_projection(projection: &mut AppProjection) { &projection.farm_readiness, ); projection.startup_gate = projection.identity.startup_gate(); + projection.personal.entry = projection.identity.personal_entry(); sync_logged_out_startup(&mut projection.logged_out_startup, projection.startup_gate); sync_farm_setup_flow_stage( &mut projection.farm_setup_flow_stage, @@ -1053,15 +1132,24 @@ fn sync_projection(projection: &mut AppProjection) { fn sync_shell_to_identity(shell: &mut AppShellProjection, identity: &AppIdentityProjection) { match identity.startup_gate() { - AppStartupGate::Blocked | AppStartupGate::SetupRequired | AppStartupGate::Personal => { + AppStartupGate::Blocked | AppStartupGate::SetupRequired => { shell.active_surface = ActiveSurface::Personal; if matches!(shell.selected_section, ShellSection::Farmer(_)) { shell.selected_section = ShellSection::Home; } } + AppStartupGate::Personal => { + shell.active_surface = ActiveSurface::Personal; + if matches!(shell.selected_section, ShellSection::Farmer(_)) { + shell.selected_section = ShellSection::default_for_surface(ActiveSurface::Personal); + } + } AppStartupGate::Farmer => { shell.active_surface = ActiveSurface::Farmer; - if matches!(shell.selected_section, ShellSection::Home) { + if matches!( + shell.selected_section, + ShellSection::Home | ShellSection::Personal(_) + ) { shell.selected_section = ShellSection::default_for_surface(ActiveSurface::Farmer); } } @@ -1274,10 +1362,10 @@ mod tests { LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, - PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, ProductEditorDraft, - ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, + PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, + ProductsListProjection, ProductsSort, SelectedAccountProjection, SelectedSurfaceProjection, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; struct FailingRepository; @@ -1335,6 +1423,7 @@ mod tests { assert_eq!(projection.products, ProductsScreenProjection::default()); assert_eq!(projection.orders, OrdersScreenProjection::default()); assert_eq!(projection.pack_day, PackDayScreenProjection::default()); + assert_eq!(projection.personal.entry.state, PersonalEntryState::Guest); assert_eq!(projection.farm_setup, FarmSetupProjection::default()); assert_eq!( projection.farm_setup_flow_stage, @@ -1378,6 +1467,10 @@ mod tests { store.projection().pack_day, PackDayScreenProjection::default() ); + assert_eq!( + store.personal_projection().entry.state, + PersonalEntryState::Guest + ); assert_eq!(store.home_route(), HomeRoute::SetupRequired); } @@ -1785,6 +1878,28 @@ mod tests { } #[test] + fn replacing_identity_projection_makes_signed_in_personal_entry_explicit_without_rewriting_home() + { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + let changed = store.apply(AppStateCommand::replace_identity_projection( + ready_identity(ActiveSurface::Personal), + )); + + assert_eq!(changed, Ok(true)); + assert_eq!(store.startup_gate(), AppStartupGate::Personal); + assert_eq!( + store.projection().shell.selected_section, + ShellSection::Home + ); + assert_eq!( + store.personal_projection().entry.state, + PersonalEntryState::SignedIn + ); + } + + #[test] fn startup_identity_choice_state_resets_once_identity_leaves_setup_required() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); @@ -1915,7 +2030,7 @@ mod tests { ); assert_eq!( store.projection().shell.selected_section, - ShellSection::Home + ShellSection::Personal(PersonalSection::Browse) ); assert_eq!(store.startup_gate(), AppStartupGate::Personal); }