app

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

commit aa104f446918b4027708157290c04b30268fe9cd
parent 478962e802cddf487f49d7ec92f8b821e60dbe02
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 17:54:21 +0000

desktop: add product editor flow

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/launchers/desktop/src/source_guards.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 864++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/shared/i18n/src/keys.rs | 21+++++++++++++++++++++
Mi18n/locales/en/messages.json | 21+++++++++++++++++++++
5 files changed, 1273 insertions(+), 80 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -5,8 +5,9 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, - ProductId, ProductsFilter, ProductsListProjection, ProductsSort, SettingsAccountProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, + ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort, + SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, }; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, @@ -95,9 +96,12 @@ impl DesktopAppRuntime { } }; - state + let section_changed = state .state_store - .apply_in_memory(AppStateCommand::SelectSection(selected_section)) + .apply_in_memory(AppStateCommand::SelectSection(selected_section)); + let editor_changed = state.close_product_editor(); + + section_changed || editor_changed } pub fn select_farmer_section(&self, section: FarmerSection) -> bool { @@ -130,6 +134,29 @@ impl DesktopAppRuntime { .update_product_stock(product_id, stock_quantity) } + pub fn open_new_product_editor(&self) -> Result<bool, AppSqliteError> { + self.lock_state_mut().open_new_product_editor() + } + + pub fn open_existing_product_editor( + &self, + product_id: ProductId, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .open_existing_product_editor(product_id) + } + + pub fn save_product_editor_draft( + &self, + draft: ProductEditorDraft, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().save_product_editor_draft(draft) + } + + pub fn close_product_editor(&self) -> bool { + self.lock_state_mut().close_product_editor() + } + pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool { let changed = self.lock_state_mut().state_store.apply_in_memory( AppStateCommand::SetSettingsPreference { @@ -453,10 +480,14 @@ impl DesktopAppRuntimeState { fn select_farmer_section(&mut self, section: FarmerSection) -> bool { match section { FarmerSection::Today => { - self.state_store - .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( - FarmerSection::Today, - ))) + let section_changed = + self.state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::Today, + ))); + let editor_changed = self.close_product_editor(); + + section_changed || editor_changed } FarmerSection::Products if self.state_store.farm_setup_projection().has_saved_farm() => @@ -555,6 +586,105 @@ impl DesktopAppRuntimeState { Ok(updated || context_changed) } + fn open_new_product_editor(&mut self) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + + let product_id = sqlite_store.create_product_draft(farm_id)?; + let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { + return Ok(false); + }; + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + )?; + let context_changed = self.apply_selected_account_context(&selected_account_context); + let section_changed = self.select_farmer_section(FarmerSection::Products); + let editor_changed = + self.state_store + .apply_in_memory(AppStateCommand::open_existing_product_editor( + product_id, draft, + )); + + Ok(context_changed || section_changed || editor_changed) + } + + fn open_existing_product_editor( + &mut self, + product_id: ProductId, + ) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { + return Ok(false); + }; + let section_changed = self.select_farmer_section(FarmerSection::Products); + let editor_changed = + self.state_store + .apply_in_memory(AppStateCommand::open_existing_product_editor( + product_id, draft, + )); + + Ok(section_changed || editor_changed) + } + + fn save_product_editor_draft( + &mut self, + draft: ProductEditorDraft, + ) -> Result<bool, AppSqliteError> { + let Some(product_id) = self.selected_product_editor_id() else { + return Ok(false); + }; + + let saved = { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + sqlite_store.save_product_editor_draft(product_id, &draft)? + }; + if !saved { + return Ok(false); + } + + let selected_account_context = { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + )? + }; + let reloaded_draft = { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + sqlite_store + .load_product_editor_draft(product_id)? + .unwrap_or(draft) + }; + let context_changed = self.apply_selected_account_context(&selected_account_context); + let editor_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_product_editor_draft( + reloaded_draft, + )); + + Ok(saved || context_changed || editor_changed) + } + + fn close_product_editor(&mut self) -> bool { + self.state_store + .apply_in_memory(AppStateCommand::close_product_editor()) + } + fn save_farm_setup_draft( &mut self, draft: FarmSetupDraft, @@ -613,8 +743,9 @@ impl DesktopAppRuntimeState { .state_store .apply_in_memory(AppStateCommand::replace_identity_projection(projection)); let context_changed = self.apply_selected_account_context(&selected_account_context); + let editor_changed = self.close_product_editor(); - Ok(identity_changed || context_changed) + Ok(identity_changed || context_changed || editor_changed) } fn refresh_selected_account_context( @@ -643,9 +774,14 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_products_list( context.products_list.clone(), )); + let editor_changed = if context.farm_setup_projection.has_saved_farm() { + false + } else { + self.close_product_editor() + }; let shell_changed = self.sync_truthful_farmer_section(); - farm_setup_changed || today_changed || products_changed || shell_changed + farm_setup_changed || today_changed || products_changed || editor_changed || shell_changed } fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> { @@ -709,6 +845,27 @@ impl DesktopAppRuntimeState { Ok(query_changed || filter_changed || sort_changed || list_changed) } + fn selected_farm_id(&self) -> Option<FarmId> { + self.state_store + .identity_projection() + .selected_account + .as_ref() + .and_then(|account| account.farmer_activation.farm_id) + .or(self + .state_store + .farm_setup_projection() + .saved_farm + .as_ref() + .map(|farm| farm.farm_id)) + } + + fn selected_product_editor_id(&self) -> Option<ProductId> { + match &self.state_store.products_projection().editor { + radroots_app_state::ProductEditorState::Open(session) => session.selected_product_id, + radroots_app_state::ProductEditorState::Closed => None, + } + } + fn load_products_list_for_query( &self, query: &ProductsScreenQueryState, @@ -716,21 +873,7 @@ impl DesktopAppRuntimeState { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(ProductsListProjection::default()); }; - let Some(selected_account) = self - .state_store - .identity_projection() - .selected_account - .as_ref() - else { - return Ok(ProductsListProjection::default()); - }; - let farm_id = selected_account.farmer_activation.farm_id.or(self - .state_store - .farm_setup_projection() - .saved_farm - .as_ref() - .map(|farm| farm.farm_id)); - let Some(farm_id) = farm_id else { + let Some(farm_id) = self.selected_farm_id() else { return Ok(ProductsListProjection::default()); }; @@ -860,9 +1003,10 @@ mod tests { use radroots_app_models::{ AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FarmerActivationProjection, FarmerSection, ProductsFilter, ProductsSort, - SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + FarmerActivationProjection, FarmerSection, ProductEditorDraft, ProductStatus, + ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + TodaySummary, }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ @@ -1516,6 +1660,197 @@ mod tests { } #[test] + fn runtime_open_new_product_editor_creates_a_local_draft_and_opens_it() { + let runtime = memory_runtime(); + + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + let farm_id = + save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); + let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: FarmReadiness::Ready, + }); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_summary( + farm_setup_projection + .saved_farm + .as_ref() + .expect("saved farm should exist"), + ) + .expect("farm summary should save"); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_setup(account_id.as_str(), &farm_setup_projection) + .expect("farm setup should save"); + + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + assert_eq!( + runtime + .summary() + .products_projection + .list + .summary + .total_products, + 0 + ); + + assert!( + runtime + .open_new_product_editor() + .expect("new product editor should open") + ); + + let summary = runtime.summary(); + assert_eq!(summary.products_projection.list.summary.total_products, 1); + assert!(matches!( + summary.products_projection.editor, + radroots_app_state::ProductEditorState::Open(_) + )); + assert_eq!( + summary.products_projection.list.rows[0].status, + ProductStatus::Draft + ); + } + + #[test] + fn runtime_open_existing_and_save_product_editor_refreshes_products_projection() { + let runtime = memory_runtime(); + + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + let farm_id = + save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); + let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: FarmReadiness::Ready, + }); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_summary( + farm_setup_projection + .saved_farm + .as_ref() + .expect("saved farm should exist"), + ) + .expect("farm summary should save"); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_setup(account_id.as_str(), &farm_setup_projection) + .expect("farm setup should save"); + let product_id = seed_product( + &runtime, + farm_id, + "Salad mix", + "Spring blend", + "draft", + Some(2), + "2026-04-18T10:00:00Z", + ); + + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + assert!( + runtime + .open_existing_product_editor(product_id) + .expect("existing product editor should open") + ); + + let saved_draft = ProductEditorDraft { + title: "Salad mix".to_owned(), + subtitle: "Washed and boxed".to_owned(), + unit_label: "box".to_owned(), + price_minor_units: Some(900), + price_currency: "usd".to_owned(), + stock_quantity: Some(14), + availability_window_id: None, + status: radroots_app_models::ProductStatus::Published, + }; + + assert!( + runtime + .save_product_editor_draft(saved_draft.clone()) + .expect("product editor draft should save") + ); + + let summary = runtime.summary(); + assert_eq!( + summary.products_projection.list.rows[0].subtitle.as_deref(), + Some("Washed and boxed") + ); + assert_eq!( + summary.products_projection.list.rows[0] + .price + .as_ref() + .map(|price| price.amount_minor_units), + Some(900) + ); + assert_eq!( + summary.products_projection.list.rows[0].stock.quantity, + Some(14) + ); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_product_editor_draft(product_id) + .expect("saved draft should load"), + Some(ProductEditorDraft { + price_currency: "USD".to_owned(), + ..saved_draft + }) + ); + } + + #[test] fn runtime_account_commands_refresh_identity_projection() { let runtime = memory_runtime(); @@ -2067,7 +2402,8 @@ mod tests { status: &str, stock_count: Option<u32>, updated_at: &str, - ) { + ) -> radroots_app_models::ProductId { + let product_id = radroots_app_models::ProductId::new(); let stock_count = stock_count .map(|value| value.to_string()) .unwrap_or_else(|| "null".to_owned()); @@ -2097,7 +2433,7 @@ mod tests { null, '{updated_at}' )", - product_id = radroots_app_models::ProductId::new(), + product_id = product_id, farm_id = farm_id, title = title, subtitle = subtitle, @@ -2113,6 +2449,8 @@ mod tests { .connection() .execute_batch(&sql) .expect("product should seed"); + + product_id } fn cleanup_paths(paths: &AppSharedAccountsPaths) { diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -3,14 +3,31 @@ use std::collections::BTreeSet; const ALLOWED_MENU_LITERALS: &[&str] = &["cmd-q", "settings window should open"]; const ALLOWED_WINDOW_LITERALS: &[&str] = &[ + "", + " ", "${dollars}.{cents:02} / {}", ", ", "0", + "14", + "14.5", + "6", + "6.", + "6.5", + "6.50", + "6.500", + "Salad mix", + "USD", + "Untitled draft", + "{}.{:02}", + "abc", "account-add", "account-open-workspace", "account-log-out", "account-more", "failed to add relay `{relay_url}`: {error}", + "failed to open existing product editor", + "failed to open new product editor", + "failed to save product editor draft", "failed to route into products view", "failed to update product stock", "failed to update products filter", @@ -37,11 +54,22 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "products-filter-need-attention", "products-filter-paused", "products-row-stock-action", + "products-row-open", "products-sort-availability", "products-sort-name", "products-sort-price", "products-sort-stock", "products-sort-updated", + "products-add-product", + "products-editor-close", + "products-editor-save", + "products-editor-status-archived", + "products-editor-status-draft", + "products-editor-status-live", + "products-editor-status-paused", + "products.editor_open_failed", + "products.editor_save_failed", + "products.new_editor_open_failed", "products-stock-editor-cancel", "products-stock-editor-close", "products-stock-editor-save", @@ -115,7 +143,28 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::ProductsColumnPrice", "AppTextKey::ProductsColumnUpdated", "AppTextKey::ProductsColumnAction", + "AppTextKey::ProductsAddAction", "AppTextKey::ProductsUpdateStockAction", + "AppTextKey::ProductsEditorTitle", + "AppTextKey::ProductsEditorBody", + "AppTextKey::ProductsEditorFieldTitle", + "AppTextKey::ProductsEditorFieldSubtitle", + "AppTextKey::ProductsEditorFieldUnit", + "AppTextKey::ProductsEditorFieldPrice", + "AppTextKey::ProductsEditorFieldStock", + "AppTextKey::ProductsEditorFieldStatus", + "AppTextKey::ProductsEditorCloseAction", + "AppTextKey::ProductsEditorSaveAction", + "AppTextKey::ProductsEditorSaveFailed", + "AppTextKey::ProductsEditorInvalidPrice", + "AppTextKey::ProductsEditorInvalidStock", + "AppTextKey::ProductsEditorPublishReadinessTitle", + "AppTextKey::ProductsEditorReady", + "AppTextKey::ProductsEditorBlockerAddProductName", + "AppTextKey::ProductsEditorBlockerChooseUnit", + "AppTextKey::ProductsEditorBlockerSetPrice", + "AppTextKey::ProductsEditorBlockerAttachAvailability", + "AppTextKey::ProductsUntitledDraft", "AppTextKey::ProductsStockEditorTitle", "AppTextKey::ProductsStockEditorFieldLabel", "AppTextKey::ProductsStockEditorSaveAction", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -14,9 +14,10 @@ use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ AppStartupGate, FarmOrderMethod, FarmReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, - FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState, ProductId, - ProductListRow, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, - TodayAgendaProjection, TodaySetupTaskKind, + FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState, + ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, ProductStatus, + ProductsFilter, ProductsListRow, ProductsSort, ShellSection, TodayAgendaProjection, + TodaySetupTaskKind, }; use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; use radroots_app_ui::{ @@ -158,6 +159,7 @@ pub struct HomeView { farm_setup_form: Option<FarmSetupFormState>, products_search: Option<ProductsSearchState>, products_stock_editor: Option<ProductsStockEditorState>, + product_editor_form: Option<ProductEditorFormState>, relay_client: Option<RadrootsNostrClient>, } @@ -170,6 +172,7 @@ impl HomeView { farm_setup_form: None, products_search: None, products_stock_editor: None, + product_editor_form: None, relay_client: None, } } @@ -361,9 +364,62 @@ impl HomeView { } } + fn sync_product_editor_form( + &mut self, + runtime_summary: &DesktopAppRuntimeSummary, + window: &mut Window, + cx: &mut Context<Self>, + ) { + let Some(account_id) = runtime_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.clone()) + else { + self.product_editor_form = None; + return; + }; + + if selected_farmer_section(runtime_summary) != FarmerSection::Products + || !runtime_summary.farm_setup_projection.has_saved_farm() + { + self.product_editor_form = None; + return; + } + + let radroots_app_state::ProductEditorState::Open(session) = + &runtime_summary.products_projection.editor + else { + self.product_editor_form = None; + return; + }; + let Some(product_id) = session.selected_product_id else { + self.product_editor_form = None; + return; + }; + let should_reset = self + .product_editor_form + .as_ref() + .map(|form| form.account_id != account_id || form.product_id != product_id) + .unwrap_or(true); + + if should_reset { + self.product_editor_form = Some(ProductEditorFormState::new( + account_id, + product_id, + session.draft.clone(), + window, + cx, + )); + } + } + fn select_farmer_section(&mut self, section: FarmerSection, cx: &mut Context<Self>) { if self.runtime.select_farmer_section(section) { self.products_stock_editor = None; + if section != FarmerSection::Products { + self.product_editor_form = None; + } cx.notify(); } } @@ -458,6 +514,7 @@ impl HomeView { window: &mut Window, cx: &mut Context<Self>, ) { + let _ = self.runtime.close_product_editor(); let Some(account_id) = self .runtime .summary() @@ -487,6 +544,7 @@ impl HomeView { window, cx, )); + self.product_editor_form = None; cx.notify(); } @@ -557,6 +615,126 @@ impl HomeView { } } + fn open_new_product_editor(&mut self, cx: &mut Context<Self>) { + match self.runtime.open_new_product_editor() { + Ok(true) => { + self.products_stock_editor = None; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "products", + event = "products.new_editor_open_failed", + error = %runtime_error, + "failed to open new product editor" + ); + } + } + } + + fn open_existing_product_editor(&mut self, product_id: ProductId, cx: &mut Context<Self>) { + match self.runtime.open_existing_product_editor(product_id) { + Ok(true) => { + self.products_stock_editor = None; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "products", + event = "products.editor_open_failed", + error = %runtime_error, + product_id = %product_id, + "failed to open existing product editor" + ); + } + } + } + + fn close_product_editor(&mut self, cx: &mut Context<Self>) { + let changed = self.runtime.close_product_editor(); + let cleared = self.product_editor_form.take().is_some(); + + if changed || cleared { + cx.notify(); + } + } + + fn handle_product_editor_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.product_editor_form.as_mut() else { + return; + }; + let matches_input = form.title_input == *state + || form.subtitle_input == *state + || form.unit_input == *state + || form.price_input == *state + || form.stock_input == *state; + + if !matches_input { + return; + } + + if form.save_failed { + form.save_failed = false; + } + + cx.notify(); + } + + fn select_product_editor_status(&mut self, status: ProductStatus, cx: &mut Context<Self>) { + let Some(form) = self.product_editor_form.as_mut() else { + return; + }; + + if form.status == status { + return; + } + + form.status = status; + form.save_failed = false; + cx.notify(); + } + + fn save_product_editor(&mut self, cx: &mut Context<Self>) { + let Some(form) = self.product_editor_form.as_mut() else { + return; + }; + let Some(draft) = form.current_draft(cx) else { + return; + }; + + match self.runtime.save_product_editor_draft(draft.clone()) { + Ok(true) => { + form.initial_draft = draft; + form.save_failed = false; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "products", + event = "products.editor_save_failed", + error = %runtime_error, + product_id = %form.product_id, + "failed to save product editor draft" + ); + form.save_failed = true; + cx.notify(); + } + } + } + fn handle_farm_name_input_event( &mut self, state: &Entity<InputState>, @@ -723,7 +901,16 @@ impl HomeView { .flex() .flex_col() .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) - .child(products_title_row(runtime)) + .child(products_title_row( + runtime, + action_button_primary( + "products-add-product", + app_shared_text(AppTextKey::ProductsAddAction), + cx.listener(|this, _, _, cx| this.open_new_product_editor(cx)), + cx, + ) + .into_any_element(), + )) .child( div() .w_full() @@ -772,6 +959,26 @@ impl HomeView { cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Price, cx)), cx, )) + .when_some(self.product_editor_form.as_ref(), |this, form| { + this.child(products_editor_surface( + form, + cx.listener(|this, _, _, cx| { + this.select_product_editor_status(ProductStatus::Draft, cx) + }), + cx.listener(|this, _, _, cx| { + this.select_product_editor_status(ProductStatus::Published, cx) + }), + cx.listener(|this, _, _, cx| { + this.select_product_editor_status(ProductStatus::Paused, cx) + }), + cx.listener(|this, _, _, cx| { + this.select_product_editor_status(ProductStatus::Archived, cx) + }), + cx.listener(|this, _, _, cx| this.close_product_editor(cx)), + cx.listener(|this, _, _, cx| this.save_product_editor(cx)), + cx, + )) + }) .child(if projection.list.is_empty() { products_empty_state_card(projection.query.filter).into_any_element() } else { @@ -813,11 +1020,27 @@ impl HomeView { row: &ProductsListRow, cx: &mut Context<Self>, ) -> AnyElement { + let is_open = self + .product_editor_form + .as_ref() + .map(|form| form.product_id == row.product_id) + .unwrap_or(false); let is_editing = self .products_stock_editor .as_ref() .map(|editor| editor.product_id == row.product_id) .unwrap_or(false); + let product = products_row_open_button( + ("products-row-open", index), + row, + is_open, + cx.listener({ + let product_id = row.product_id; + move |this, _, _, cx| this.open_existing_product_editor(product_id, cx) + }), + cx, + ) + .into_any_element(); let action = if is_editing { action_button_compact( "products-stock-editor-cancel", @@ -847,7 +1070,7 @@ impl HomeView { .flex() .flex_col() .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) - .child(products_table_row(row, action)) + .child(products_table_row(product, row, action)) .when(is_editing, |this| { this.when_some(self.products_stock_editor.as_ref(), |this, editor| { this.child(products_stock_editor_card( @@ -869,6 +1092,7 @@ impl Render for HomeView { self.sync_farm_setup_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); match home_stage(&runtime_summary) { HomeStage::Setup => self .startup_view @@ -1035,6 +1259,125 @@ impl ProductsStockEditorState { } } +struct ProductEditorFormState { + account_id: String, + product_id: ProductId, + initial_draft: ProductEditorDraft, + status: ProductStatus, + title_input: Entity<InputState>, + subtitle_input: Entity<InputState>, + unit_input: Entity<InputState>, + price_input: Entity<InputState>, + stock_input: Entity<InputState>, + _title_subscription: Subscription, + _subtitle_subscription: Subscription, + _unit_subscription: Subscription, + _price_subscription: Subscription, + _stock_subscription: Subscription, + save_failed: bool, +} + +impl ProductEditorFormState { + fn new( + account_id: String, + product_id: ProductId, + draft: ProductEditorDraft, + window: &mut Window, + cx: &mut Context<HomeView>, + ) -> Self { + let title_input = + cx.new(|cx| InputState::new(window, cx).default_value(draft.title.clone())); + let subtitle_input = + cx.new(|cx| InputState::new(window, cx).default_value(draft.subtitle.clone())); + let unit_input = + cx.new(|cx| InputState::new(window, cx).default_value(draft.unit_label.clone())); + let price_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value(product_editor_price_input_value(draft.price_minor_units)) + }); + let stock_input = cx.new(|cx| { + InputState::new(window, cx).default_value( + draft + .stock_quantity + .map(|quantity| quantity.to_string()) + .unwrap_or_default(), + ) + }); + let title_subscription = cx.subscribe_in( + &title_input, + window, + HomeView::handle_product_editor_input_event, + ); + let subtitle_subscription = cx.subscribe_in( + &subtitle_input, + window, + HomeView::handle_product_editor_input_event, + ); + let unit_subscription = cx.subscribe_in( + &unit_input, + window, + HomeView::handle_product_editor_input_event, + ); + let price_subscription = cx.subscribe_in( + &price_input, + window, + HomeView::handle_product_editor_input_event, + ); + let stock_subscription = cx.subscribe_in( + &stock_input, + window, + HomeView::handle_product_editor_input_event, + ); + + Self { + account_id, + product_id, + status: draft.status, + initial_draft: draft, + title_input, + subtitle_input, + unit_input, + price_input, + stock_input, + _title_subscription: title_subscription, + _subtitle_subscription: subtitle_subscription, + _unit_subscription: unit_subscription, + _price_subscription: price_subscription, + _stock_subscription: stock_subscription, + save_failed: false, + } + } + + fn current_draft(&self, cx: &App) -> Option<ProductEditorDraft> { + Some(ProductEditorDraft { + title: self.title_input.read(cx).value().to_string(), + subtitle: self.subtitle_input.read(cx).value().to_string(), + unit_label: self.unit_input.read(cx).value().to_string(), + price_minor_units: parse_product_editor_price_input( + self.price_input.read(cx).value().as_ref(), + )?, + price_currency: "USD".to_owned(), + stock_quantity: parse_optional_product_editor_stock_input( + self.stock_input.read(cx).value().as_ref(), + )?, + availability_window_id: self.initial_draft.availability_window_id, + status: self.status, + }) + } + + fn has_changes(&self, cx: &App) -> bool { + self.current_draft(cx) + .map(|draft| draft != self.initial_draft) + .unwrap_or(false) + } + + fn publish_blockers(&self, cx: &App) -> Vec<ProductPublishBlocker> { + self.current_draft(cx) + .map(|draft| draft.publish_blockers()) + .unwrap_or_default() + } +} + #[derive(Clone, Debug, Eq, PartialEq)] enum StartupPhase { Idle, @@ -2276,29 +2619,40 @@ fn home_sidebar_nav_button( } } -fn products_title_row(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { +fn products_title_row( + runtime: &DesktopAppRuntimeSummary, + add_product_action: AnyElement, +) -> impl IntoElement { div() .w_full() .flex() - .flex_col() - .gap(px(4.0)) - .child( - div() - .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0)) - .font_weight(gpui::FontWeight::BOLD) - .text_color(rgb(APP_UI_THEME.text.primary)) - .child(app_shared_text(AppTextKey::ProductsTitle)), - ) + .items_end() + .justify_between() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) .child( div() - .text_size(px(APP_UI_THEME.typography.body_text_px)) - .font_weight(gpui::FontWeight::MEDIUM) - .line_height(relative(1.2)) - .text_color(rgb(APP_UI_THEME.text.primary)) - .when_some(home_saved_farm(runtime), |this, farm| { - this.child(farm.display_name.clone()) - }), + .flex() + .flex_col() + .gap(px(4.0)) + .child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(rgb(APP_UI_THEME.text.primary)) + .child(app_shared_text(AppTextKey::ProductsTitle)), + ) + .child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.text.primary)) + .when_some(home_saved_farm(runtime), |this, farm| { + this.child(farm.display_name.clone()) + }), + ), ) + .child(add_product_action) } fn products_controls_card( @@ -2514,36 +2868,17 @@ fn products_table_header_column( .child(app_shared_text(key)) } -fn products_table_row(row: &ProductsListRow, action: AnyElement) -> impl IntoElement { +fn products_table_row( + product: AnyElement, + row: &ProductsListRow, + action: AnyElement, +) -> impl IntoElement { div() .w_full() .flex() .items_center() .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .gap(px(4.0)) - .child( - div() - .text_size(px(APP_UI_THEME.typography.body_text_px)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(rgb(APP_UI_THEME.text.primary)) - .child(row.title.clone()), - ) - .when_some(row.subtitle.as_ref(), |this, subtitle| { - this.child( - div() - .text_size(px(APP_UI_THEME.typography.utility_title_text_px)) - .line_height(relative(1.2)) - .text_color(rgb(APP_UI_THEME.text.secondary)) - .child(subtitle.clone()), - ) - }), - ) + .child(product) .child( div() .w(px(112.0)) @@ -2593,6 +2928,64 @@ fn products_table_row(row: &ProductsListRow, action: AnyElement) -> impl IntoEle .child(div().w(px(120.0)).flex().justify_end().child(action)) } +fn products_row_open_button( + id: (&'static str, usize), + row: &ProductsListRow, + is_open: bool, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + let selected_background = rgb(APP_UI_THEME.surfaces.window_background); + + Button::new(id) + .custom( + ButtonCustomVariant::new(cx) + .color(if is_open { + selected_background.into() + } else { + transparent_black().into() + }) + .foreground(rgb(APP_UI_THEME.text.primary).into()) + .border(transparent_black()) + .hover(selected_background.into()) + .active(selected_background.into()), + ) + .rounded(ButtonRounded::Size(px(APP_UI_THEME + .controls + .action_button + .sizing + .corner_radius_px))) + .flex_1() + .min_w_0() + .on_click(on_click) + .child( + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(4.0)) + .px(px(8.0)) + .py(px(6.0)) + .child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(rgb(APP_UI_THEME.text.primary)) + .child(product_display_title(row.title.as_str())), + ) + .when_some(row.subtitle.as_ref(), |this, subtitle| { + this.child( + div() + .text_size(px(APP_UI_THEME.typography.utility_title_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .child(subtitle.clone()), + ) + }), + ) +} + fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement { let (title_key, body_key) = if filter == ProductsFilter::NeedAttention { ( @@ -2725,7 +3118,7 @@ fn products_stock_editor_card( .text_size(px(APP_UI_THEME.typography.utility_title_text_px)) .line_height(relative(1.2)) .text_color(rgb(APP_UI_THEME.text.secondary)) - .child(row.title.clone()), + .child(product_display_title(row.title.as_str())), ), ) .child( @@ -2816,10 +3209,350 @@ fn products_stock_editor_validation_key( Some(AppTextKey::ProductsStockEditorInvalidQuantity) } +fn products_editor_surface( + form: &ProductEditorFormState, + on_select_draft: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_live: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_paused: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_archived: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_save: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + let validation_keys = products_editor_validation_keys(form, cx); + let save_ready = form.has_changes(cx) && validation_keys.is_empty(); + + div().w_full().flex().justify_center().child( + div().w_full().max_w(px(520.0)).child(home_card( + app_shared_text(AppTextKey::ProductsEditorTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .child(home_body_text(app_shared_text( + AppTextKey::ProductsEditorBody, + ))) + .child(products_editor_text_field( + AppTextKey::ProductsEditorFieldTitle, + &form.title_input, + None, + )) + .child(products_editor_text_field( + AppTextKey::ProductsEditorFieldSubtitle, + &form.subtitle_input, + None, + )) + .child(products_editor_text_field( + AppTextKey::ProductsEditorFieldUnit, + &form.unit_input, + None, + )) + .child(products_editor_text_field( + AppTextKey::ProductsEditorFieldPrice, + &form.price_input, + products_editor_invalid_price_key(form, cx), + )) + .child(products_editor_text_field( + AppTextKey::ProductsEditorFieldStock, + &form.stock_input, + products_editor_invalid_stock_key(form, cx), + )) + .child(products_editor_status_section( + form.status, + on_select_draft, + on_select_live, + on_select_paused, + on_select_archived, + cx, + )) + .child(products_editor_publish_readiness_section(form, cx)) + .when(form.save_failed, |this| { + this.child(home_body_text(app_shared_text( + AppTextKey::ProductsEditorSaveFailed, + ))) + }) + .child( + div() + .w_full() + .flex() + .items_center() + .justify_between() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .child( + div() + .text_size(px(APP_UI_THEME.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .child(product_display_title( + form.title_input.read(cx).value().as_ref(), + )), + ) + .child( + div() + .flex() + .items_center() + .gap(px(8.0)) + .child(action_button_compact( + "products-editor-close", + app_shared_text(AppTextKey::ProductsEditorCloseAction), + on_close, + cx, + )) + .child(if save_ready { + action_button_primary( + "products-editor-save", + app_shared_text(AppTextKey::ProductsEditorSaveAction), + on_save, + cx, + ) + .into_any_element() + } else { + action_button_primary_disabled( + "products-editor-save", + app_shared_text(AppTextKey::ProductsEditorSaveAction), + cx, + ) + .into_any_element() + }), + ), + ), + )), + ) +} + +fn products_editor_text_field( + field_label_key: AppTextKey, + input: &Entity<InputState>, + validation_key: Option<AppTextKey>, +) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .child(home_farm_setup_field_label(field_label_key)) + .child(Input::new(input).with_size(ComponentSize::Large).w_full()) + .when_some(validation_key, |this, validation_key| { + this.child(home_body_text(app_shared_text(validation_key))) + }) +} + +fn products_editor_status_section( + selected_status: ProductStatus, + on_select_draft: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_live: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_paused: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_archived: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .child(home_farm_setup_field_label( + AppTextKey::ProductsEditorFieldStatus, + )) + .child( + div() + .w_full() + .flex() + .items_center() + .gap(px(8.0)) + .child(products_filter_button( + "products-editor-status-draft", + AppTextKey::ProductsStatusDraft, + selected_status == ProductStatus::Draft, + on_select_draft, + cx, + )) + .child(products_filter_button( + "products-editor-status-live", + AppTextKey::ProductsStatusLive, + selected_status == ProductStatus::Published, + on_select_live, + cx, + )) + .child(products_filter_button( + "products-editor-status-paused", + AppTextKey::ProductsStatusPaused, + selected_status == ProductStatus::Paused, + on_select_paused, + cx, + )) + .child(products_filter_button( + "products-editor-status-archived", + AppTextKey::ProductsStatusArchived, + selected_status == ProductStatus::Archived, + on_select_archived, + cx, + )), + ) +} + +fn products_editor_publish_readiness_section( + form: &ProductEditorFormState, + cx: &App, +) -> impl IntoElement { + let blockers = form.publish_blockers(cx); + + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .child(home_farm_setup_field_label( + AppTextKey::ProductsEditorPublishReadinessTitle, + )) + .child(if blockers.is_empty() { + home_body_text(app_shared_text(AppTextKey::ProductsEditorReady)).into_any_element() + } else { + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .children( + blockers + .into_iter() + .map(products_editor_publish_blocker_row) + .collect::<Vec<_>>(), + ) + .into_any_element() + }) +} + +fn products_editor_publish_blocker_row(blocker: ProductPublishBlocker) -> AnyElement { + div() + .w_full() + .flex() + .items_start() + .gap(px(APP_UI_THEME.layout.settings_account_status_gap_px)) + .child(status_indicator( + APP_UI_THEME.controls.status_indicator.attention, + )) + .child(home_body_text(app_shared_text( + products_editor_publish_blocker_key(blocker), + ))) + .into_any_element() +} + +fn products_editor_publish_blocker_key(blocker: ProductPublishBlocker) -> AppTextKey { + match blocker { + ProductPublishBlocker::AddProductName => AppTextKey::ProductsEditorBlockerAddProductName, + ProductPublishBlocker::ChooseUnit => AppTextKey::ProductsEditorBlockerChooseUnit, + ProductPublishBlocker::SetPrice => AppTextKey::ProductsEditorBlockerSetPrice, + ProductPublishBlocker::AttachAvailability => { + AppTextKey::ProductsEditorBlockerAttachAvailability + } + } +} + +fn products_editor_validation_keys(form: &ProductEditorFormState, cx: &App) -> Vec<AppTextKey> { + let mut keys = Vec::new(); + + if let Some(key) = products_editor_invalid_price_key(form, cx) { + keys.push(key); + } + + if let Some(key) = products_editor_invalid_stock_key(form, cx) { + keys.push(key); + } + + keys +} + +fn products_editor_invalid_price_key( + form: &ProductEditorFormState, + cx: &App, +) -> Option<AppTextKey> { + parse_product_editor_price_input(form.price_input.read(cx).value().as_ref()) + .is_none() + .then_some(AppTextKey::ProductsEditorInvalidPrice) +} + +fn products_editor_invalid_stock_key( + form: &ProductEditorFormState, + cx: &App, +) -> Option<AppTextKey> { + parse_optional_product_editor_stock_input(form.stock_input.read(cx).value().as_ref()) + .is_none() + .then_some(AppTextKey::ProductsEditorInvalidStock) +} + fn parse_products_stock_quantity(input: &str) -> Option<u32> { input.trim().parse().ok() } +fn parse_product_editor_price_input(input: &str) -> Option<Option<u32>> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Some(None); + } + + let parse_whole_dollars = |value: &str| -> Option<u32> { value.parse::<u32>().ok() }; + + if let Some((dollars, cents)) = trimmed.split_once('.') { + if trimmed.matches('.').count() != 1 || cents.is_empty() || cents.len() > 2 { + return None; + } + + let dollars = if dollars.is_empty() { + 0 + } else { + parse_whole_dollars(dollars)? + }; + let cents = match cents.len() { + 1 => cents.parse::<u32>().ok()?.checked_mul(10)?, + 2 => cents.parse::<u32>().ok()?, + _ => return None, + }; + + return dollars + .checked_mul(100) + .and_then(|amount| amount.checked_add(cents)) + .map(Some); + } + + parse_whole_dollars(trimmed) + .and_then(|dollars| dollars.checked_mul(100)) + .map(Some) +} + +fn parse_optional_product_editor_stock_input(input: &str) -> Option<Option<u32>> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Some(None); + } + + trimmed.parse::<u32>().ok().map(Some) +} + +fn product_editor_price_input_value(price_minor_units: Option<u32>) -> String { + price_minor_units + .map(|amount_minor_units| { + format!( + "{}.{:02}", + amount_minor_units / 100, + amount_minor_units % 100 + ) + }) + .unwrap_or_default() +} + +fn product_display_title(title: &str) -> String { + let trimmed = title.trim(); + if trimmed.is_empty() { + app_shared_text(AppTextKey::ProductsUntitledDraft).to_string() + } else { + trimmed.to_owned() + } +} + fn home_farm_setup_onboarding_card( spec: FarmSetupOnboardingCardSpec, on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -3278,7 +4011,7 @@ fn home_low_stock_row(product: &ProductListRow) -> AnyElement { .text_size(px(APP_UI_THEME.typography.body_text_px)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(rgb(APP_UI_THEME.text.primary)) - .child(product.title.clone()), + .child(product_display_title(product.title.as_str())), ) .child( div() @@ -3325,7 +4058,7 @@ fn home_draft_row(product: &ProductListRow) -> AnyElement { .text_size(px(APP_UI_THEME.typography.body_text_px)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(rgb(APP_UI_THEME.text.primary)) - .child(product.title.clone()), + .child(product_display_title(product.title.as_str())), ) .child(status_indicator( APP_UI_THEME.controls.status_indicator.offline, @@ -3501,6 +4234,8 @@ mod tests { use super::{ AppTextKey, FarmerHomeFarmState, farm_setup_onboarding_card_spec, farmer_home_farm_state, home_saved_farm, home_window_launch_size_px, home_window_minimum_size_px, + parse_optional_product_editor_stock_input, parse_product_editor_price_input, + product_display_title, }; use crate::runtime::DesktopAppRuntimeSummary; use radroots_app_models::SettingsAccountProjection; @@ -3623,6 +4358,35 @@ mod tests { assert_eq!(home_saved_farm(&runtime), Some(&saved_farm)); } + #[test] + fn product_editor_price_parser_handles_blank_whole_and_decimal_inputs() { + assert_eq!(parse_product_editor_price_input(""), Some(None)); + assert_eq!(parse_product_editor_price_input("6"), Some(Some(600))); + assert_eq!(parse_product_editor_price_input("6.5"), Some(Some(650))); + assert_eq!(parse_product_editor_price_input("6.50"), Some(Some(650))); + assert_eq!(parse_product_editor_price_input("6."), None); + assert_eq!(parse_product_editor_price_input("6.500"), None); + assert_eq!(parse_product_editor_price_input("abc"), None); + } + + #[test] + fn product_editor_stock_parser_accepts_blank_or_whole_numbers_only() { + assert_eq!(parse_optional_product_editor_stock_input(""), Some(None)); + assert_eq!( + parse_optional_product_editor_stock_input("14"), + Some(Some(14)) + ); + assert_eq!(parse_optional_product_editor_stock_input("14.5"), None); + assert_eq!(parse_optional_product_editor_stock_input("abc"), None); + } + + #[test] + fn blank_product_titles_fall_back_to_the_untitled_copy() { + assert_eq!(product_display_title(""), "Untitled draft"); + assert_eq!(product_display_title(" "), "Untitled draft"); + assert_eq!(product_display_title("Salad mix"), "Salad mix"); + } + fn summary( home_route: HomeRoute, today_projection: TodayAgendaProjection, diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -97,7 +97,28 @@ define_app_text_keys! { ProductsColumnPrice => "products.column.price", ProductsColumnUpdated => "products.column.updated", ProductsColumnAction => "products.column.action", + ProductsAddAction => "products.action.add", ProductsUpdateStockAction => "products.action.update_stock", + ProductsEditorTitle => "products.editor.title", + ProductsEditorBody => "products.editor.body", + ProductsEditorFieldTitle => "products.editor.field.title", + ProductsEditorFieldSubtitle => "products.editor.field.subtitle", + ProductsEditorFieldUnit => "products.editor.field.unit", + ProductsEditorFieldPrice => "products.editor.field.price", + ProductsEditorFieldStock => "products.editor.field.stock", + ProductsEditorFieldStatus => "products.editor.field.status", + ProductsEditorCloseAction => "products.editor.action.close", + ProductsEditorSaveAction => "products.editor.action.save", + ProductsEditorSaveFailed => "products.editor.save_failed", + ProductsEditorInvalidPrice => "products.editor.invalid_price", + ProductsEditorInvalidStock => "products.editor.invalid_stock", + ProductsEditorPublishReadinessTitle => "products.editor.publish_readiness.title", + ProductsEditorReady => "products.editor.publish_readiness.ready", + ProductsEditorBlockerAddProductName => "products.editor.blocker.add_product_name", + ProductsEditorBlockerChooseUnit => "products.editor.blocker.choose_unit", + ProductsEditorBlockerSetPrice => "products.editor.blocker.set_price", + ProductsEditorBlockerAttachAvailability => "products.editor.blocker.attach_availability", + ProductsUntitledDraft => "products.untitled_draft", ProductsStockEditorTitle => "products.stock_editor.title", ProductsStockEditorFieldLabel => "products.stock_editor.field.label", ProductsStockEditorSaveAction => "products.stock_editor.action.save", diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -76,7 +76,28 @@ "products.column.price": "Price", "products.column.updated": "Updated", "products.column.action": "Action", + "products.action.add": "Add product", "products.action.update_stock": "Update stock", + "products.editor.title": "Product details", + "products.editor.body": "Saved locally on this device.", + "products.editor.field.title": "Name", + "products.editor.field.subtitle": "Details", + "products.editor.field.unit": "Unit", + "products.editor.field.price": "Price (USD)", + "products.editor.field.stock": "Stock", + "products.editor.field.status": "Status", + "products.editor.action.close": "Close", + "products.editor.action.save": "Save changes", + "products.editor.save_failed": "Couldn't save product details. Try again.", + "products.editor.invalid_price": "Enter dollars and cents, for example 6.50.", + "products.editor.invalid_stock": "Enter a whole number or leave blank.", + "products.editor.publish_readiness.title": "Publish readiness", + "products.editor.publish_readiness.ready": "This product is ready to publish.", + "products.editor.blocker.add_product_name": "Add a product name.", + "products.editor.blocker.choose_unit": "Choose a unit.", + "products.editor.blocker.set_price": "Set a price.", + "products.editor.blocker.attach_availability": "Attach an availability window.", + "products.untitled_draft": "Untitled draft", "products.stock_editor.title": "Update stock", "products.stock_editor.field.label": "Stock", "products.stock_editor.action.save": "Save stock",