commit aa89d5ba882454dad2131c1b7399385b63671e78
parent 47a33fc94968da3e89809548d290d514545bb35b
Author: triesap <tyson@radroots.org>
Date: Mon, 20 Apr 2026 18:29:24 +0000
marketplace: add buyer cart checkout flow
- add the dedicated buyer cart surface with removable line items and order summary
- add a single-column checkout card with local-only contact fields and place-order flow
- write checkout changes into sqlite-backed cart and order state then route into buyer orders
- align mounted app account selection calls with the current nostr_accounts manager api
Diffstat:
8 files changed, 997 insertions(+), 68 deletions(-)
diff --git a/crates/launchers/desktop/src/accounts.rs b/crates/launchers/desktop/src/accounts.rs
@@ -11,8 +11,8 @@ use radroots_app_models::{
use radroots_app_sqlite::{AppSqliteError, AppSqliteStore};
use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityId};
use radroots_nostr_accounts::prelude::{
- RadrootsNostrAccountRecord, RadrootsNostrAccountsError, RadrootsNostrAccountsManager,
- RadrootsNostrSelectedAccountStatus,
+ RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError,
+ RadrootsNostrAccountsManager,
};
use radroots_secret_vault::{
RadrootsHostVaultCapabilities, RadrootsSecretBackend, RadrootsSecretBackendAvailability,
@@ -107,7 +107,7 @@ pub fn select_local_account(
account_id: &str,
) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
let account_id = RadrootsIdentityId::parse(account_id.trim())?;
- manager.select_account(&account_id)?;
+ manager.set_default_account(&account_id)?;
Ok(identity_projection_from_manager(manager, sqlite_store)?)
}
@@ -116,7 +116,7 @@ pub fn select_active_surface(
sqlite_store: &AppSqliteStore,
active_surface: ActiveSurface,
) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
- let Some(selected_account) = manager.selected_account()? else {
+ let Some(selected_account) = selected_account_record(manager)? else {
return Ok(identity_projection_from_manager(manager, sqlite_store)?);
};
let selected_projection =
@@ -135,13 +135,16 @@ pub fn remove_selected_local_key(
manager: &RadrootsNostrAccountsManager,
sqlite_store: &AppSqliteStore,
) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
- let Some(selected_account) = manager.selected_account()? else {
+ let Some(selected_account) = selected_account_record(manager)? else {
return Ok(identity_projection_from_manager(manager, sqlite_store)?);
};
let account_id = selected_account.account_id.to_string();
sqlite_store.clear_surface_activation(account_id.as_str())?;
manager.remove_account(&selected_account.account_id)?;
+ if let Some(next_account) = manager.list_accounts()?.into_iter().next() {
+ manager.set_default_account(&next_account.account_id)?;
+ }
Ok(identity_projection_from_manager(manager, sqlite_store)?)
}
@@ -253,17 +256,15 @@ pub(crate) fn identity_projection_from_manager(
let roster_records = manager.list_accounts()?;
let roster = account_roster_from_records(roster_records.as_slice());
- match manager.selected_account_status()? {
- RadrootsNostrSelectedAccountStatus::NotConfigured => {
+ match manager.default_account_status()? {
+ RadrootsNostrAccountStatus::NotConfigured => {
Ok(AppIdentityProjection::missing_with_roster(roster))
}
- RadrootsNostrSelectedAccountStatus::PublicOnly { account }
- | RadrootsNostrSelectedAccountStatus::Ready { account } => {
- Ok(AppIdentityProjection::ready(
- roster,
- selected_account_projection_from_record(&account, sqlite_store)?,
- ))
- }
+ RadrootsNostrAccountStatus::PublicOnly { account }
+ | RadrootsNostrAccountStatus::Ready { account } => Ok(AppIdentityProjection::ready(
+ roster,
+ selected_account_projection_from_record(&account, sqlite_store)?,
+ )),
}
}
@@ -287,6 +288,16 @@ fn selected_account_projection_from_record(
)
}
+fn selected_account_record(
+ manager: &RadrootsNostrAccountsManager,
+) -> Result<Option<RadrootsNostrAccountRecord>, RadrootsNostrAccountsError> {
+ match manager.default_account_status()? {
+ RadrootsNostrAccountStatus::NotConfigured => Ok(None),
+ RadrootsNostrAccountStatus::PublicOnly { account }
+ | RadrootsNostrAccountStatus::Ready { account } => Ok(Some(account)),
+ }
+}
+
fn default_farmer_surface_activation(account_id: &str) -> AccountSurfaceActivationProjection {
AccountSurfaceActivationProjection::new(
account_id,
@@ -375,6 +386,7 @@ mod tests {
bootstrap_desktop_accounts_with_availability, generate_local_account,
identity_projection_from_manager, import_local_account, remove_selected_local_key,
reset_local_device_state, select_local_account, selected_account_projection_from_record,
+ selected_account_record,
};
fn temp_shared_accounts_paths(label: &str) -> AppSharedAccountsPaths {
@@ -431,8 +443,7 @@ mod tests {
let account_id = manager
.generate_identity(Some("North field".to_owned()), true)
.expect("account should generate");
- let selected_account = manager
- .selected_account()
+ let selected_account = selected_account_record(&manager)
.expect("selected account should load")
.expect("selected account should exist");
let selected_account_summary = account_summary_from_record(&selected_account);
@@ -618,15 +629,17 @@ mod tests {
Some(ActiveSurface::Farmer)
);
assert_eq!(
- manager.selected_account_id().expect("selected account id"),
- Some(second_account_id)
+ selected_account_record(&manager)
+ .expect("selected account")
+ .map(|account| account.account_id),
+ Some(second_account_id.clone())
);
assert_ne!(
first_account_id,
- manager
- .selected_account_id()
- .expect("selected account id")
+ selected_account_record(&manager)
+ .expect("selected account")
.expect("selected")
+ .account_id
);
}
@@ -645,7 +658,7 @@ mod tests {
.generate_identity(Some("Second".to_owned()), false)
.expect("second account should generate");
manager
- .select_account(&first_account_id)
+ .set_default_account(&first_account_id)
.expect("first account should remain selected");
let activation = AccountSurfaceActivationProjection::new(
first_account_id.as_str(),
diff --git a/crates/launchers/desktop/src/remote_signer.rs b/crates/launchers/desktop/src/remote_signer.rs
@@ -387,7 +387,9 @@ mod tests {
RadrootsAppRemoteSignerSessionRecord, radroots_app_remote_signer_requested_permissions,
};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
- use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager;
+ use radroots_nostr_accounts::prelude::{
+ RadrootsNostrAccountStatus, RadrootsNostrAccountsManager,
+ };
use super::{
DesktopRemoteSignerPaths, activate_pending_session, apply_remote_signer_custody,
@@ -486,10 +488,14 @@ mod tests {
)
.expect("activate pending");
- let selected = manager
- .selected_account()
- .expect("selected account")
- .expect("configured account");
+ let selected = match manager
+ .default_account_status()
+ .expect("selected account status")
+ {
+ RadrootsNostrAccountStatus::NotConfigured => panic!("configured account"),
+ RadrootsNostrAccountStatus::PublicOnly { account }
+ | RadrootsNostrAccountStatus::Ready { account } => account,
+ };
assert_eq!(
selected.account_id.as_str(),
approved.user_identity.id.as_str()
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -6,13 +6,13 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA
use radroots_app_models::{
ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate,
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,
+ BuyerCheckoutDraft, 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,
@@ -226,6 +226,21 @@ impl DesktopAppRuntime {
.clear_personal_cart_replace_confirmation()
}
+ pub fn remove_personal_cart_line(&self, product_id: ProductId) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().remove_personal_cart_line(product_id)
+ }
+
+ pub fn save_personal_checkout_draft(
+ &self,
+ draft: BuyerCheckoutDraft,
+ ) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().save_personal_checkout_draft(draft)
+ }
+
+ pub fn place_personal_order(&self) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().place_personal_order()
+ }
+
pub fn set_personal_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> {
self.lock_state_mut()
.set_personal_search_query(search_query)
@@ -909,6 +924,94 @@ impl DesktopAppRuntimeState {
})
}
+ fn remove_personal_cart_line(&mut self, product_id: ProductId) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let buyer_context = self.state_store.identity_projection().buyer_context();
+ let current_cart = sqlite_store.load_buyer_cart(&buyer_context)?;
+ let Some(next_cart) = next_buyer_cart_after_removing_line(current_cart, product_id)? else {
+ return Ok(false);
+ };
+
+ if next_cart.lines.is_empty() {
+ sqlite_store.clear_buyer_cart(&buyer_context)?;
+ } else {
+ 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)?;
+
+ Ok(self.refresh_personal_cart_and_checkout(refreshed_cart, refreshed_checkout))
+ }
+
+ fn save_personal_checkout_draft(
+ &mut self,
+ draft: BuyerCheckoutDraft,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let buyer_context = self.state_store.identity_projection().buyer_context();
+ sqlite_store.save_buyer_checkout_draft(&buyer_context, &draft)?;
+ let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?;
+
+ Ok(self.mutate_personal_projection(|projection| {
+ if projection.cart.checkout == refreshed_checkout {
+ return false;
+ }
+
+ projection.cart.checkout = refreshed_checkout;
+ true
+ }))
+ }
+
+ fn place_personal_order(&mut self) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let buyer_context = self.state_store.identity_projection().buyer_context();
+ let order_id = sqlite_store.place_buyer_order(&buyer_context)?;
+ let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?;
+ let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?;
+ let refreshed_orders = sqlite_store.load_buyer_orders(&buyer_context)?;
+ if !refreshed_orders
+ .rows
+ .iter()
+ .any(|row| row.order_id == order_id)
+ {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer order write did not surface in buyer order history",
+ });
+ }
+
+ let personal_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;
+ }
+ if projection.orders.list != refreshed_orders {
+ projection.orders.list = refreshed_orders.clone();
+ changed = true;
+ }
+ if projection.orders.detail.is_some() {
+ projection.orders.detail = None;
+ changed = true;
+ }
+
+ changed
+ });
+ let section_changed = self.select_personal_section(PersonalSection::Orders);
+
+ Ok(personal_changed || section_changed)
+ }
+
fn set_personal_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> {
let query = self.state_store.personal_projection().search.query.clone();
if query.search_query == search_query {
@@ -1629,6 +1732,26 @@ impl DesktopAppRuntimeState {
sqlite_store.load_buyer_listings(&query.search_query, &query.fulfillment_methods)
}
+ fn refresh_personal_cart_and_checkout(
+ &mut self,
+ refreshed_cart: BuyerCartProjection,
+ refreshed_checkout: radroots_app_models::BuyerCheckoutProjection,
+ ) -> bool {
+ 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
+ })
+ }
+
fn replace_products_query(
&mut self,
query: ProductsScreenQueryState,
@@ -2102,6 +2225,36 @@ fn next_buyer_cart_for_detail(
Ok(current_cart)
}
+fn next_buyer_cart_after_removing_line(
+ mut current_cart: BuyerCartProjection,
+ product_id: ProductId,
+) -> Result<Option<BuyerCartProjection>, AppSqliteError> {
+ let previous_line_count = current_cart.lines.len();
+ current_cart
+ .lines
+ .retain(|line| line.product_id != product_id);
+ if current_cart.lines.len() == previous_line_count {
+ return Ok(None);
+ }
+
+ if current_cart.lines.is_empty() {
+ current_cart.farm_id = None;
+ current_cart.farm_display_name = None;
+ current_cart.replace_confirmation = None;
+ refresh_buyer_cart_totals(&mut current_cart)?;
+ return Ok(Some(current_cart));
+ }
+
+ let farm_id = current_cart.lines[0].farm_id;
+ let farm_display_name = current_cart.lines[0].farm_display_name.clone();
+ current_cart.farm_id = Some(farm_id);
+ current_cart.farm_display_name = Some(farm_display_name);
+ current_cart.replace_confirmation = None;
+ refresh_buyer_cart_totals(&mut current_cart)?;
+
+ Ok(Some(current_cart))
+}
+
fn buyer_cart_line_from_detail(
detail: &BuyerProductDetailProjection,
) -> Result<BuyerCartLineProjection, AppSqliteError> {
@@ -2271,14 +2424,14 @@ mod tests {
};
use radroots_app_models::{
AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate,
- BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmOrderMethod,
- FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft,
- FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection,
- FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId,
- OrderStatus, OrdersFilter, PersonalSection, PickupLocationId, PickupLocationRecord,
- ProductEditorDraft, ProductStatus, ProductsFilter, ProductsSort, SelectedSurfaceProjection,
- SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask,
- TodaySetupTaskKind, TodaySummary,
+ BlackoutPeriodId, BlackoutPeriodRecord, BuyerCheckoutDraft, FarmId,
+ FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness,
+ FarmReadinessBlocker, FarmSetupDraft, FarmSetupProjection, FarmSummary,
+ FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord,
+ LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PersonalSection,
+ PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter,
+ ProductsSort, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection,
+ TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
};
use radroots_app_remote_signer::{
RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
@@ -3304,6 +3457,153 @@ mod tests {
}
#[test]
+ fn runtime_removing_buyer_cart_line_clears_cart_and_checkout_readiness() {
+ 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
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("buyer product should add to cart")
+ );
+
+ assert!(
+ runtime
+ .remove_personal_cart_line(product_id)
+ .expect("buyer cart line should remove")
+ );
+
+ let summary = runtime.summary();
+ assert!(summary.personal_projection.cart.cart.lines.is_empty());
+ assert!(summary.personal_projection.cart.cart.farm_id.is_none());
+ assert!(!summary.personal_projection.cart.checkout.can_place_order);
+ assert_eq!(
+ summary.personal_projection.cart.checkout.summary.line_count,
+ 0
+ );
+ }
+
+ #[test]
+ fn runtime_places_buyer_order_and_routes_into_personal_orders() {
+ 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
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("buyer product should add to cart")
+ );
+ assert!(
+ runtime
+ .save_personal_checkout_draft(BuyerCheckoutDraft {
+ name: "Casey Buyer".to_owned(),
+ email: "casey@example.com".to_owned(),
+ phone: "555-0101".to_owned(),
+ order_note: "Leave by the cooler".to_owned(),
+ })
+ .expect("buyer checkout draft should save")
+ );
+ assert!(
+ runtime
+ .place_personal_order()
+ .expect("buyer order should place")
+ );
+
+ let summary = runtime.summary();
+ assert_eq!(
+ summary.shell_projection.selected_section,
+ ShellSection::Personal(PersonalSection::Orders)
+ );
+ assert!(summary.personal_projection.cart.cart.lines.is_empty());
+ assert!(!summary.personal_projection.cart.checkout.can_place_order);
+ assert_eq!(summary.personal_projection.orders.list.rows.len(), 1);
+ assert_eq!(
+ summary.personal_projection.orders.list.rows[0].farm_display_name,
+ "North field farm"
+ );
+ assert_eq!(
+ summary.personal_projection.orders.list.rows[0]
+ .status
+ .storage_key(),
+ "placed"
+ );
+ }
+
+ #[test]
fn runtime_products_queries_refresh_the_repository_backed_projection() {
let runtime = memory_runtime();
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -9,7 +9,7 @@ const ALLOWED_MENU_LITERALS: &[&str] = &["cmd-q", "settings window should open"]
const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"",
" ",
- " from {farm}",
+ "${dollars}.{cents:02}",
"${dollars}.{cents:02} / {}",
", ",
"+",
@@ -41,14 +41,24 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"buyer-detail-keep-current",
"buyer-detail-quantity-decrease",
"buyer-detail-quantity-increase",
+ "buyer-cart-open-checkout",
+ "buyer-cart-remove-line",
+ "buyer-checkout-back",
+ "buyer-checkout-place-order",
"buyer-listing-open",
"buyer.add_to_cart_failed",
+ "buyer.cart_remove_failed",
+ "buyer.checkout_place_failed",
+ "buyer.checkout_save_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 place buyer order",
+ "failed to remove buyer cart line",
+ "failed to save buyer checkout draft",
"failed to open buyer product detail",
"failed to update buyer fulfillment filter",
"failed to update buyer search query",
@@ -202,7 +212,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"startup-title-radroots",
"startup-title-starting",
"wss://relay.radroots.example",
- "{} items are ready in your cart{}.",
+ "{currency_code} {dollars}.{cents:02}",
"{} {} {}.",
"{} local orders are already available on this device.",
"{quantity} {unit_label}",
@@ -267,6 +277,17 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::PersonalSearchPlaceholderBody",
"AppTextKey::PersonalCartPlaceholderBody",
"AppTextKey::PersonalOrdersPlaceholderBody",
+ "AppTextKey::PersonalCartSurfaceBody",
+ "AppTextKey::PersonalOrderSummaryTitle",
+ "AppTextKey::PersonalFulfillmentTitle",
+ "AppTextKey::PersonalCartRemoveLineAction",
+ "AppTextKey::PersonalCartContinueCheckoutAction",
+ "AppTextKey::PersonalCartLineQuantityLabel",
+ "AppTextKey::PersonalCartLineUnitPriceLabel",
+ "AppTextKey::PersonalCartLineTotalLabel",
+ "AppTextKey::PersonalSummaryFarmLabel",
+ "AppTextKey::PersonalSummaryItemsLabel",
+ "AppTextKey::PersonalSummarySubtotalLabel",
"AppTextKey::PersonalDetailBackAction",
"AppTextKey::PersonalDetailQuantityLabel",
"AppTextKey::PersonalDetailAddToCartAction",
@@ -274,6 +295,15 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::PersonalDetailReplaceCartBody",
"AppTextKey::PersonalDetailReplaceCartAction",
"AppTextKey::PersonalDetailKeepCurrentCartAction",
+ "AppTextKey::PersonalCheckoutTitle",
+ "AppTextKey::PersonalCheckoutBackAction",
+ "AppTextKey::PersonalCheckoutContactTitle",
+ "AppTextKey::PersonalCheckoutFieldName",
+ "AppTextKey::PersonalCheckoutFieldEmail",
+ "AppTextKey::PersonalCheckoutFieldPhone",
+ "AppTextKey::PersonalCheckoutFieldOrderNote",
+ "AppTextKey::PersonalCheckoutLocalOnlyBody",
+ "AppTextKey::PersonalCheckoutPlaceOrderAction",
"AppTextKey::HomeTodayOpenInOrdersAction",
"AppTextKey::HomeTodayOpenInPackDayAction",
"AppTextKey::OrdersTitle",
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -11,7 +11,8 @@ use gpui_component::{
use radroots_app_i18n::AppTextKey;
pub use radroots_app_models::SettingsSection as SettingsPanelViewKey;
use radroots_app_models::{
- AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCartReplaceConfirmationProjection,
+ AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCartProjection,
+ BuyerCartReplaceConfirmationProjection, BuyerCheckoutDraft, BuyerCheckoutSummaryProjection,
BuyerListingRow, BuyerProductDetailProjection, FarmId, FarmOperatingRulesRecord,
FarmOrderMethod, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection,
FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind,
@@ -187,6 +188,7 @@ pub struct HomeView {
startup_signer_recovery_attempted: bool,
farm_setup_form: Option<FarmSetupFormState>,
personal_search: Option<PersonalSearchState>,
+ buyer_checkout_form: Option<BuyerCheckoutFormState>,
products_search: Option<ProductsSearchState>,
products_stock_editor: Option<ProductsStockEditorState>,
product_editor_form: Option<ProductEditorFormState>,
@@ -233,6 +235,7 @@ impl HomeView {
startup_signer_recovery_attempted: false,
farm_setup_form: None,
personal_search: None,
+ buyer_checkout_form: None,
products_search: None,
products_stock_editor: None,
product_editor_form: None,
@@ -780,6 +783,44 @@ impl HomeView {
}
}
+ fn sync_buyer_checkout_form(
+ &mut self,
+ runtime_summary: &DesktopAppRuntimeSummary,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if home_stage(runtime_summary) != HomeStage::BuyerWorkspace
+ || selected_personal_section(runtime_summary) != PersonalSection::Cart
+ || runtime_summary
+ .personal_projection
+ .cart
+ .cart
+ .lines
+ .is_empty()
+ {
+ self.buyer_checkout_form = None;
+ return;
+ }
+
+ let workspace_id = personal_workspace_id(runtime_summary);
+ let draft = &runtime_summary.personal_projection.cart.checkout.draft;
+ let should_reset = self
+ .buyer_checkout_form
+ .as_ref()
+ .map(|form| form.workspace_id != workspace_id)
+ .unwrap_or(false);
+
+ if should_reset {
+ self.buyer_checkout_form =
+ Some(BuyerCheckoutFormState::new(workspace_id, draft, window, cx));
+ return;
+ }
+
+ if let Some(form) = self.buyer_checkout_form.as_mut() {
+ form.sync(draft, window, cx);
+ }
+ }
+
fn sync_products_stock_editor(&mut self, runtime_summary: &DesktopAppRuntimeSummary) {
let Some(editor) = self.products_stock_editor.as_ref() else {
return;
@@ -1010,6 +1051,45 @@ impl HomeView {
}
}
+ fn handle_buyer_checkout_input_event(
+ &mut self,
+ state: &Entity<InputState>,
+ event: &InputEvent,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if !matches!(event, InputEvent::Change) {
+ return;
+ }
+
+ let Some(form) = self.buyer_checkout_form.as_ref() else {
+ return;
+ };
+ let matches_input = form.name_input == *state
+ || form.email_input == *state
+ || form.phone_input == *state
+ || form.order_note_input == *state;
+ if !matches_input {
+ return;
+ }
+
+ match self
+ .runtime
+ .save_personal_checkout_draft(form.current_draft(cx))
+ {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "buyer",
+ event = "buyer.checkout_save_failed",
+ error = %runtime_error,
+ "failed to save buyer checkout draft"
+ );
+ }
+ }
+ }
+
fn toggle_personal_search_fulfillment_method(
&mut self,
method: FarmOrderMethod,
@@ -1112,6 +1192,73 @@ impl HomeView {
}
}
+ fn open_personal_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if self.buyer_checkout_form.is_some() {
+ return;
+ }
+
+ let runtime_summary = self.runtime.summary();
+ if home_stage(&runtime_summary) != HomeStage::BuyerWorkspace
+ || selected_personal_section(&runtime_summary) != PersonalSection::Cart
+ || runtime_summary
+ .personal_projection
+ .cart
+ .cart
+ .lines
+ .is_empty()
+ {
+ return;
+ }
+
+ self.buyer_checkout_form = Some(BuyerCheckoutFormState::new(
+ personal_workspace_id(&runtime_summary),
+ &runtime_summary.personal_projection.cart.checkout.draft,
+ window,
+ cx,
+ ));
+ cx.notify();
+ }
+
+ fn close_personal_checkout(&mut self, cx: &mut Context<Self>) {
+ if self.buyer_checkout_form.take().is_some() {
+ cx.notify();
+ }
+ }
+
+ fn remove_personal_cart_line(&mut self, product_id: ProductId, cx: &mut Context<Self>) {
+ match self.runtime.remove_personal_cart_line(product_id) {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "buyer",
+ event = "buyer.cart_remove_failed",
+ error = %runtime_error,
+ product_id = %product_id,
+ "failed to remove buyer cart line"
+ );
+ }
+ }
+ }
+
+ fn place_personal_order(&mut self, cx: &mut Context<Self>) {
+ match self.runtime.place_personal_order() {
+ Ok(true) => {
+ self.buyer_checkout_form = None;
+ cx.notify();
+ }
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "buyer",
+ event = "buyer.checkout_place_failed",
+ error = %runtime_error,
+ "failed to place buyer order"
+ );
+ }
+ }
+ }
+
fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) {
match self.runtime.select_products_filter(filter) {
Ok(true) => {
@@ -1917,7 +2064,9 @@ impl HomeView {
PersonalSection::Search => self
.render_buyer_search_content(runtime, cx)
.into_any_element(),
- PersonalSection::Cart => buyer_cart_placeholder(runtime).into_any_element(),
+ PersonalSection::Cart => self
+ .render_buyer_cart_content(runtime, cx)
+ .into_any_element(),
PersonalSection::Orders => buyer_orders_placeholder(runtime).into_any_element(),
};
@@ -2227,6 +2376,50 @@ impl HomeView {
.into_any_element()
}
+ fn render_buyer_cart_content(
+ &mut self,
+ runtime: &DesktopAppRuntimeSummary,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let cart = &runtime.personal_projection.cart.cart;
+ let checkout = &runtime.personal_projection.cart.checkout;
+
+ app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
+ .w_full()
+ .max_w(px(APP_UI_THEME.shells.home_card_max_width_px))
+ .mx_auto()
+ .child(buyer_workspace_title_block(
+ AppTextKey::HomeNavCart,
+ AppTextKey::PersonalCartSurfaceBody,
+ ))
+ .child(if cart.lines.is_empty() {
+ app_surface_card(home_body_text(app_shared_text(
+ AppTextKey::PersonalCartPlaceholderBody,
+ )))
+ .into_any_element()
+ } else {
+ app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
+ .w_full()
+ .child(buyer_cart_card(
+ cart,
+ &checkout.summary,
+ self.buyer_checkout_form.is_some(),
+ cx,
+ ))
+ .when_some(self.buyer_checkout_form.as_ref(), |this, form| {
+ this.child(buyer_checkout_card(
+ form,
+ checkout,
+ cx.listener(|this, _, _, cx| this.close_personal_checkout(cx)),
+ cx.listener(|this, _, _, cx| this.place_personal_order(cx)),
+ cx,
+ ))
+ })
+ .into_any_element()
+ })
+ .into_any_element()
+ }
+
fn render_farmer_workspace(
&mut self,
runtime: &DesktopAppRuntimeSummary,
@@ -2801,6 +2994,7 @@ impl Render for HomeView {
self.sync_startup_signer_entry(&runtime_summary, window, cx);
self.sync_farm_setup_form(&runtime_summary, window, cx);
self.sync_personal_search(&runtime_summary, window, cx);
+ self.sync_buyer_checkout_form(&runtime_summary, window, cx);
self.sync_products_search(&runtime_summary, window, cx);
self.sync_products_stock_editor(&runtime_summary);
self.sync_product_editor_form(&runtime_summary, window, cx);
@@ -2924,6 +3118,108 @@ impl PersonalSearchState {
}
}
+struct BuyerCheckoutFormState {
+ workspace_id: String,
+ name_input: Entity<InputState>,
+ email_input: Entity<InputState>,
+ phone_input: Entity<InputState>,
+ order_note_input: Entity<InputState>,
+ _name_subscription: Subscription,
+ _email_subscription: Subscription,
+ _phone_subscription: Subscription,
+ _order_note_subscription: Subscription,
+}
+
+impl BuyerCheckoutFormState {
+ fn new(
+ workspace_id: String,
+ draft: &BuyerCheckoutDraft,
+ window: &mut Window,
+ cx: &mut Context<HomeView>,
+ ) -> Self {
+ let name_input = cx.new(|cx| InputState::new(window, cx).default_value(draft.name.clone()));
+ let email_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.email.clone()));
+ let phone_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.phone.clone()));
+ let order_note_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.order_note.clone()));
+ let name_subscription = cx.subscribe_in(
+ &name_input,
+ window,
+ HomeView::handle_buyer_checkout_input_event,
+ );
+ let email_subscription = cx.subscribe_in(
+ &email_input,
+ window,
+ HomeView::handle_buyer_checkout_input_event,
+ );
+ let phone_subscription = cx.subscribe_in(
+ &phone_input,
+ window,
+ HomeView::handle_buyer_checkout_input_event,
+ );
+ let order_note_subscription = cx.subscribe_in(
+ &order_note_input,
+ window,
+ HomeView::handle_buyer_checkout_input_event,
+ );
+
+ Self {
+ workspace_id,
+ name_input,
+ email_input,
+ phone_input,
+ order_note_input,
+ _name_subscription: name_subscription,
+ _email_subscription: email_subscription,
+ _phone_subscription: phone_subscription,
+ _order_note_subscription: order_note_subscription,
+ }
+ }
+
+ fn sync(
+ &mut self,
+ draft: &BuyerCheckoutDraft,
+ window: &mut Window,
+ cx: &mut Context<HomeView>,
+ ) {
+ sync_checkout_input(&self.name_input, draft.name.as_str(), window, cx);
+ sync_checkout_input(&self.email_input, draft.email.as_str(), window, cx);
+ sync_checkout_input(&self.phone_input, draft.phone.as_str(), window, cx);
+ sync_checkout_input(
+ &self.order_note_input,
+ draft.order_note.as_str(),
+ window,
+ cx,
+ );
+ }
+
+ fn current_draft(&self, cx: &App) -> BuyerCheckoutDraft {
+ BuyerCheckoutDraft {
+ name: self.name_input.read(cx).value().to_string(),
+ email: self.email_input.read(cx).value().to_string(),
+ phone: self.phone_input.read(cx).value().to_string(),
+ order_note: self.order_note_input.read(cx).value().to_string(),
+ }
+ }
+}
+
+fn sync_checkout_input(
+ input: &Entity<InputState>,
+ value: &str,
+ window: &mut Window,
+ cx: &mut Context<HomeView>,
+) {
+ if input.read(cx).value().as_ref() == value {
+ return;
+ }
+
+ input.update(cx, |input, cx| {
+ input.set_value(value.to_owned(), window, cx);
+ });
+}
+
struct ProductsSearchState {
account_id: String,
input: Entity<InputState>,
@@ -5819,6 +6115,246 @@ fn buyer_product_detail_card(
)
}
+fn buyer_cart_card(
+ cart: &BuyerCartProjection,
+ summary: &BuyerCheckoutSummaryProjection,
+ checkout_open: bool,
+ cx: &mut Context<HomeView>,
+) -> impl IntoElement {
+ app_surface_card(
+ app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
+ .w_full()
+ .children(
+ cart.lines
+ .iter()
+ .enumerate()
+ .map(|(index, line)| buyer_cart_line_card(index, line, cx).into_any_element())
+ .collect::<Vec<_>>(),
+ )
+ .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::PersonalOrderSummaryTitle,
+ )))
+ .child(label_value_list(buyer_order_summary_rows(summary))),
+ ))
+ .when(!checkout_open, |this| {
+ this.child(action_button_primary(
+ "buyer-cart-open-checkout",
+ app_shared_text(AppTextKey::PersonalCartContinueCheckoutAction),
+ cx.listener(|this, _, window, cx| this.open_personal_checkout(window, cx)),
+ cx,
+ ))
+ }),
+ )
+}
+
+fn buyer_cart_line_card(
+ index: usize,
+ line: &radroots_app_models::BuyerCartLineProjection,
+ cx: &mut Context<HomeView>,
+) -> impl IntoElement {
+ 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(
+ 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_label(product_display_title(line.title.as_str())))
+ .child(settings_badge_text(line.farm_display_name.clone())),
+ )
+ .child(action_button_compact(
+ ("buyer-cart-remove-line", index),
+ app_shared_text(AppTextKey::PersonalCartRemoveLineAction),
+ cx.listener({
+ let product_id = line.product_id;
+ move |this, _, _, cx| this.remove_personal_cart_line(product_id, cx)
+ }),
+ cx,
+ )),
+ )
+ .child(label_value_list(vec![
+ LabelValueRow::new(
+ app_shared_text(AppTextKey::PersonalCartLineQuantityLabel),
+ line.quantity.to_string(),
+ ),
+ LabelValueRow::new(
+ app_shared_text(AppTextKey::PersonalCartLineUnitPriceLabel),
+ buyer_listing_price_text(&line.unit_price),
+ ),
+ LabelValueRow::new(
+ app_shared_text(AppTextKey::PersonalCartLineTotalLabel),
+ buyer_money_text(
+ line.line_total_minor_units,
+ line.unit_price.currency_code.as_str(),
+ ),
+ ),
+ ]))
+ .child(buyer_listing_chip(line.fulfillment_summary.clone())),
+ )
+}
+
+fn buyer_checkout_card(
+ form: &BuyerCheckoutFormState,
+ checkout: &radroots_app_models::BuyerCheckoutProjection,
+ on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_place_order: 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_text_value(app_shared_text(
+ AppTextKey::PersonalCheckoutTitle,
+ )))
+ .child(text_button(
+ "buyer-checkout-back",
+ app_shared_text(AppTextKey::PersonalCheckoutBackAction),
+ on_close,
+ cx,
+ )),
+ )
+ .child(home_body_text(app_shared_text(
+ AppTextKey::PersonalCheckoutLocalOnlyBody,
+ )))
+ .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::PersonalOrderSummaryTitle,
+ )))
+ .child(label_value_list(buyer_order_summary_rows(
+ &checkout.summary,
+ ))),
+ ))
+ .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::PersonalFulfillmentTitle,
+ )))
+ .child(home_body_text(
+ checkout
+ .summary
+ .fulfillment_summary
+ .clone()
+ .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()),
+ )),
+ ))
+ .child(app_form_section(
+ app_shared_text(AppTextKey::PersonalCheckoutContactTitle),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.shells.home_stack_gap_px))
+ .child(app_form_input_text(
+ AppFormFieldSpec::new(
+ app_shared_text(AppTextKey::PersonalCheckoutFieldName),
+ Option::<SharedString>::None,
+ ),
+ &form.name_input,
+ false,
+ ))
+ .child(app_form_input_text(
+ AppFormFieldSpec::new(
+ app_shared_text(AppTextKey::PersonalCheckoutFieldEmail),
+ Option::<SharedString>::None,
+ ),
+ &form.email_input,
+ false,
+ ))
+ .child(app_form_input_text(
+ AppFormFieldSpec::new(
+ app_shared_text(AppTextKey::PersonalCheckoutFieldPhone),
+ Option::<SharedString>::None,
+ ),
+ &form.phone_input,
+ false,
+ ))
+ .child(app_form_input_text(
+ AppFormFieldSpec::new(
+ app_shared_text(AppTextKey::PersonalCheckoutFieldOrderNote),
+ Option::<SharedString>::None,
+ ),
+ &form.order_note_input,
+ false,
+ )),
+ ))
+ .child(if checkout.can_place_order {
+ action_button_primary(
+ "buyer-checkout-place-order",
+ app_shared_text(AppTextKey::PersonalCheckoutPlaceOrderAction),
+ on_place_order,
+ cx,
+ )
+ .into_any_element()
+ } else {
+ action_button_primary_disabled(
+ "buyer-checkout-place-order",
+ app_shared_text(AppTextKey::PersonalCheckoutPlaceOrderAction),
+ cx,
+ )
+ .into_any_element()
+ }),
+ )
+}
+
+fn buyer_order_summary_rows(summary: &BuyerCheckoutSummaryProjection) -> Vec<LabelValueRow> {
+ vec![
+ LabelValueRow::new(
+ app_shared_text(AppTextKey::PersonalSummaryFarmLabel),
+ summary
+ .farm_display_name
+ .clone()
+ .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()),
+ ),
+ LabelValueRow::new(
+ app_shared_text(AppTextKey::PersonalSummaryItemsLabel),
+ summary.line_count.to_string(),
+ ),
+ LabelValueRow::new(
+ app_shared_text(AppTextKey::PersonalSummarySubtotalLabel),
+ summary
+ .subtotal_minor_units
+ .zip(summary.currency_code.as_deref())
+ .map(|(amount, currency_code)| buyer_money_text(amount, currency_code))
+ .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()),
+ ),
+ ]
+}
+
+fn buyer_money_text(amount_minor_units: u32, currency_code: &str) -> String {
+ let dollars = amount_minor_units / 100;
+ let cents = amount_minor_units % 100;
+
+ if currency_code == "USD" {
+ format!("${dollars}.{cents:02}")
+ } else {
+ format!("{currency_code} {dollars}.{cents:02}")
+ }
+}
+
fn buyer_surface_placeholder(
title_key: AppTextKey,
body_key: AppTextKey,
@@ -5838,28 +6374,6 @@ fn buyer_surface_placeholder(
.into_any_element()
}
-fn buyer_cart_placeholder(runtime: &DesktopAppRuntimeSummary) -> AnyElement {
- let cart = &runtime.personal_projection.cart.cart;
- let detail = if cart.lines.is_empty() {
- None
- } else {
- Some(format!(
- "{} items are ready in your cart{}.",
- cart.lines.len(),
- cart.farm_display_name
- .as_ref()
- .map(|farm| format!(" from {farm}"))
- .unwrap_or_default()
- ))
- };
-
- buyer_surface_placeholder(
- AppTextKey::HomeNavCart,
- AppTextKey::PersonalCartPlaceholderBody,
- detail,
- )
-}
-
fn buyer_orders_placeholder(runtime: &DesktopAppRuntimeSummary) -> AnyElement {
let detail = (!runtime.personal_projection.orders.list.rows.is_empty()).then_some(format!(
"{} local orders are already available on this device.",
@@ -6584,6 +7098,15 @@ fn selected_personal_section(runtime: &DesktopAppRuntimeSummary) -> PersonalSect
}
}
+fn personal_workspace_id(runtime: &DesktopAppRuntimeSummary) -> String {
+ runtime
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .map(|account| account.account.account_id.clone())
+ .unwrap_or_else(|| "guest".to_owned())
+}
+
fn farmer_products_available(runtime: &DesktopAppRuntimeSummary) -> bool {
runtime.farm_setup_projection.has_saved_farm()
}
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -110,6 +110,17 @@ define_app_text_keys! {
PersonalSearchPlaceholderBody => "personal.search.placeholder.body",
PersonalCartPlaceholderBody => "personal.cart.placeholder.body",
PersonalOrdersPlaceholderBody => "personal.orders.placeholder.body",
+ PersonalCartSurfaceBody => "personal.cart.surface.body",
+ PersonalOrderSummaryTitle => "personal.order_summary.title",
+ PersonalFulfillmentTitle => "personal.fulfillment.title",
+ PersonalCartRemoveLineAction => "personal.cart.remove_line.action",
+ PersonalCartContinueCheckoutAction => "personal.cart.continue_checkout.action",
+ PersonalCartLineQuantityLabel => "personal.cart.line.quantity.label",
+ PersonalCartLineUnitPriceLabel => "personal.cart.line.unit_price.label",
+ PersonalCartLineTotalLabel => "personal.cart.line.total.label",
+ PersonalSummaryFarmLabel => "personal.summary.farm.label",
+ PersonalSummaryItemsLabel => "personal.summary.items.label",
+ PersonalSummarySubtotalLabel => "personal.summary.subtotal.label",
PersonalDetailBackAction => "personal.detail.back_action",
PersonalDetailQuantityLabel => "personal.detail.quantity.label",
PersonalDetailAddToCartAction => "personal.detail.add_to_cart.action",
@@ -117,6 +128,15 @@ define_app_text_keys! {
PersonalDetailReplaceCartBody => "personal.detail.replace_cart.body",
PersonalDetailReplaceCartAction => "personal.detail.replace_cart.action",
PersonalDetailKeepCurrentCartAction => "personal.detail.keep_current_cart.action",
+ PersonalCheckoutTitle => "personal.checkout.title",
+ PersonalCheckoutBackAction => "personal.checkout.back_action",
+ PersonalCheckoutContactTitle => "personal.checkout.contact.title",
+ PersonalCheckoutFieldName => "personal.checkout.field.name",
+ PersonalCheckoutFieldEmail => "personal.checkout.field.email",
+ PersonalCheckoutFieldPhone => "personal.checkout.field.phone",
+ PersonalCheckoutFieldOrderNote => "personal.checkout.field.order_note",
+ PersonalCheckoutLocalOnlyBody => "personal.checkout.local_only.body",
+ PersonalCheckoutPlaceOrderAction => "personal.checkout.place_order.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
@@ -209,6 +209,23 @@ mod tests {
}
#[test]
+ fn english_marketplace_checkout_copy_matches_the_local_order_contract() {
+ assert_eq!(
+ app_text(AppTextKey::PersonalCartContinueCheckoutAction),
+ "Continue to checkout"
+ );
+ assert_eq!(app_text(AppTextKey::PersonalCheckoutTitle), "Checkout");
+ assert_eq!(
+ app_text(AppTextKey::PersonalCheckoutPlaceOrderAction),
+ "Place order"
+ );
+ assert_eq!(
+ app_text(AppTextKey::PersonalCheckoutLocalOnlyBody),
+ "This places a local order on this device. It does not charge a card."
+ );
+ }
+
+ #[test]
fn english_pack_day_copy_matches_the_contextual_execution_contract() {
assert_eq!(app_text(AppTextKey::PackDayTitle), "Pack day");
assert_eq!(
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -89,6 +89,17 @@
"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.cart.surface.body": "Review items from one farm and continue to checkout when you're ready.",
+ "personal.order_summary.title": "Order summary",
+ "personal.fulfillment.title": "Fulfillment",
+ "personal.cart.remove_line.action": "Remove",
+ "personal.cart.continue_checkout.action": "Continue to checkout",
+ "personal.cart.line.quantity.label": "Quantity",
+ "personal.cart.line.unit_price.label": "Unit price",
+ "personal.cart.line.total.label": "Line total",
+ "personal.summary.farm.label": "Farm",
+ "personal.summary.items.label": "Items",
+ "personal.summary.subtotal.label": "Subtotal",
"personal.detail.back_action": "Back",
"personal.detail.quantity.label": "Quantity",
"personal.detail.add_to_cart.action": "Add to cart",
@@ -96,6 +107,15 @@
"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",
+ "personal.checkout.title": "Checkout",
+ "personal.checkout.back_action": "Back to cart",
+ "personal.checkout.contact.title": "Contact",
+ "personal.checkout.field.name": "Name",
+ "personal.checkout.field.email": "Email",
+ "personal.checkout.field.phone": "Phone",
+ "personal.checkout.field.order_note": "Order note",
+ "personal.checkout.local_only.body": "This places a local order on this device. It does not charge a card.",
+ "personal.checkout.place_order.action": "Place order",
"orders.title": "Orders",
"orders.filters.title": "View",
"orders.summary.total": "Total orders",