app

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

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:
Mcrates/launchers/desktop/src/accounts.rs | 57+++++++++++++++++++++++++++++++++++----------------------
Mcrates/launchers/desktop/src/remote_signer.rs | 16+++++++++++-----
Mcrates/launchers/desktop/src/runtime.rs | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/launchers/desktop/src/source_guards.rs | 34++++++++++++++++++++++++++++++++--
Mcrates/launchers/desktop/src/window.rs | 571+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/i18n/src/keys.rs | 20++++++++++++++++++++
Mcrates/shared/i18n/src/lib.rs | 17+++++++++++++++++
Mi18n/locales/en/messages.json | 20++++++++++++++++++++
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",