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:
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);
}