app

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

commit 95d6afd692a2e468ed10514d334d4c0bf041790b
parent 95a59b3f228c94121d5bd242f738d32fd75051b3
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 16:28:05 +0000

app: add config flow screens

- add profile, role, and preferences configuration steps
- add role-specific form sections with product chip inputs
- wire config flow validation into continue and back actions
- route to app after completing the config flow

Diffstat:
Mapp/src/app.rs | 550+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 542 insertions(+), 8 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -17,6 +17,9 @@ use radroots_app_ui_components::{ RadrootsAppUiButtonLayoutAction, RadrootsAppUiButtonLayoutBackAction, RadrootsAppUiButtonLayoutPair, + RadrootsAppUiChip, + RadrootsAppUiChips, + RadrootsAppUiFormField, RadrootsAppUiIcon, RadrootsAppUiIconKey, RadrootsAppUiNavHeader, @@ -42,6 +45,8 @@ use crate::{ app_init_stage_set, app_init_total_add, app_init_total_unknown, + app_config_flow_validate, + app_config_step_default, app_context, app_i18n_init, app_log_buffer_flush_deferred, @@ -67,6 +72,8 @@ use crate::{ app_setup_gate_from_status, app_setup_step_default, RadrootsAppBackends, + RadrootsAppConfigFlowDraft, + RadrootsAppConfigStep, RadrootsAppConfigStatus, RadrootsAppInitError, RadrootsAppInitStage, @@ -1518,16 +1525,543 @@ fn RecoveryPage() -> impl IntoView { #[component] fn ConfigPage() -> impl IntoView { + let context = app_context(); + let fallback_config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown); + let config_status = context + .as_ref() + .map(|value| value.config_status) + .unwrap_or(fallback_config_status); + let navigate = use_navigate(); + let config_step = RwSignal::new_local(app_config_step_default()); + let profile_name = RwSignal::new_local(String::new()); + let profile_location = RwSignal::new_local(String::new()); + let role = RwSignal::new_local(None::<RadrootsAppRole>); + let farmer_farm_name = RwSignal::new_local(String::new()); + let farmer_location = RwSignal::new_local(String::new()); + let farmer_products = RwSignal::new_local(Vec::<String>::new()); + let farmer_products_input = RwSignal::new_local(String::new()); + let individual_name = RwSignal::new_local(String::new()); + let individual_location = RwSignal::new_local(String::new()); + let individual_products = RwSignal::new_local(Vec::<String>::new()); + let individual_products_input = RwSignal::new_local(String::new()); + let business_name = RwSignal::new_local(String::new()); + let business_location = RwSignal::new_local(String::new()); + let business_operations = RwSignal::new_local(String::new()); + let notifications_orders = RwSignal::new_local(true); + let notifications_messages = RwSignal::new_local(true); + let payment_method = RwSignal::new_local(String::new()); + let config_flow = move || RadrootsAppConfigFlowDraft { + step: config_step.get(), + profile_name: profile_name.get(), + profile_location: profile_location.get(), + role: role.get(), + farmer_farm_name: farmer_farm_name.get(), + farmer_location: farmer_location.get(), + farmer_products: farmer_products.get(), + individual_name: individual_name.get(), + individual_location: individual_location.get(), + individual_products: individual_products.get(), + business_name: business_name.get(), + business_location: business_location.get(), + business_operations: business_operations.get(), + notifications_orders: notifications_orders.get(), + notifications_messages: notifications_messages.get(), + payment_method: payment_method.get(), + }; + let config_validation = move || app_config_flow_validate(&config_flow()); + let advance_step = { + let config_status = config_status.clone(); + let navigate = navigate.clone(); + Callback::new(move |_| { + let validation = config_validation(); + if !validation.can_continue { + return; + } + if matches!(config_step.get(), RadrootsAppConfigStep::Preferences) { + config_status.set(RadrootsAppConfigStatus::Configured); + navigate("/", Default::default()); + return; + } + config_step.set(validation.next_step); + }) + }; + let advance_step_click = { + let advance_step = advance_step.clone(); + Callback::new(move |_ev: MouseEvent| { + advance_step.run(()); + }) + }; + let rewind_step = Callback::new(move |_ev: MouseEvent| { + let validation = config_validation(); + config_step.set(validation.prev_step); + }); + let add_farmer_product = { + let farmer_products = farmer_products.clone(); + let farmer_products_input = farmer_products_input.clone(); + move || { + let entry = farmer_products_input.get_untracked(); + let trimmed = entry.trim().to_string(); + if trimmed.is_empty() { + return; + } + farmer_products.update(|items| { + if !items.iter().any(|item| item.eq_ignore_ascii_case(&trimmed)) { + items.push(trimmed.clone()); + } + }); + farmer_products_input.set(String::new()); + } + }; + let add_individual_product = { + let individual_products = individual_products.clone(); + let individual_products_input = individual_products_input.clone(); + move || { + let entry = individual_products_input.get_untracked(); + let trimmed = entry.trim().to_string(); + if trimmed.is_empty() { + return; + } + individual_products.update(|items| { + if !items.iter().any(|item| item.eq_ignore_ascii_case(&trimmed)) { + items.push(trimmed.clone()); + } + }); + individual_products_input.set(String::new()); + } + }; view! { - <main id="app-config" class="app-page app-page-fixed"> - <section id="app-config-body" class="flex flex-col gap-2 px-4 pt-6"> - <h1 id="app-config-title" class="text-xl font-semibold"> - {"Configure your profile"} - </h1> - <p id="app-config-subtitle" class="text-sm text-[var(--text-secondary)]"> - {"Complete your configuration to continue."} - </p> + <main id="app-config" class="app-page app-page-fixed relative w-full flex flex-col"> + <section + id="app-config-body" + class="app-page-scroll scroll-hide flex flex-col flex-1 min-h-0 w-full gap-6 px-4 pt-6 pb-28 overscroll-contain" + > + <header id="app-config-header" class="flex flex-col gap-2"> + <p class="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ly1-gl-label/60"> + {"Configuration"} + </p> + <h1 id="app-config-title" class="text-2xl font-semibold text-ly0-gl"> + {"Set up your profile"} + </h1> + <p id="app-config-step" class="text-sm text-ly1-gl-label/80"> + {move || { + let step = config_step.get(); + let index = match step { + RadrootsAppConfigStep::Profile => 1, + RadrootsAppConfigStep::Role => 2, + RadrootsAppConfigStep::Preferences => 3, + }; + format!("Step {index} of 3") + }} + </p> + </header> + {move || match config_step.get() { + RadrootsAppConfigStep::Profile => view! { + <section + id="app-config-profile" + class="app-view app-view-enter flex flex-col w-full gap-5" + > + <RadrootsAppUiFormField + label="Profile name".to_string() + id="app-config-profile-name-field".to_string() + > + <input + id="app-config-profile-name" + class="input-base" + type="text" + placeholder="Your name".to_string() + prop:value=move || profile_name.get() + on:input=move |ev| { + profile_name.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Location".to_string() + id="app-config-profile-location-field".to_string() + hint="City or region".to_string() + > + <input + id="app-config-profile-location" + class="input-base" + type="text" + placeholder="e.g. Sonoma, CA".to_string() + prop:value=move || profile_location.get() + on:keydown=move |ev: KeyboardEvent| { + if ev.key() == "Enter" { + ev.prevent_default(); + advance_step.run(()); + } + } + on:input=move |ev| { + profile_location.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + </section> + }.into_any(), + RadrootsAppConfigStep::Role => view! { + <section + id="app-config-role" + class="app-view app-view-enter flex flex-col w-full gap-5" + > + <RadrootsAppUiFormField + label="Role".to_string() + id="app-config-role-select".to_string() + hint="Choose the best fit for you right now".to_string() + > + <RadrootsAppUiChips id="app-config-role-options".to_string()> + <button + id="app-config-role-farmer" + type="button" + class="form-chip" + attr:data-active=move || if role.get() == Some(RadrootsAppRole::Farm) { "true" } else { "false" } + on:click=move |_| { + role.set(Some(RadrootsAppRole::Farm)); + } + > + {"Farmer"} + </button> + <button + id="app-config-role-business" + type="button" + class="form-chip" + attr:data-active=move || if role.get() == Some(RadrootsAppRole::Business) { "true" } else { "false" } + on:click=move |_| { + role.set(Some(RadrootsAppRole::Business)); + } + > + {"Business"} + </button> + <button + id="app-config-role-individual" + type="button" + class="form-chip" + attr:data-active=move || if role.get() == Some(RadrootsAppRole::Individual) { "true" } else { "false" } + on:click=move |_| { + role.set(Some(RadrootsAppRole::Individual)); + } + > + {"Individual"} + </button> + </RadrootsAppUiChips> + </RadrootsAppUiFormField> + {move || match role.get() { + Some(RadrootsAppRole::Farm) => view! { + <div + id="app-config-role-farm" + class="flex flex-col gap-4" + > + <RadrootsAppUiFormField + label="Farm name".to_string() + id="app-config-farm-name-field".to_string() + > + <input + id="app-config-farm-name" + class="input-base" + type="text" + placeholder="e.g. Willow Creek Farm".to_string() + prop:value=move || farmer_farm_name.get() + on:input=move |ev| { + farmer_farm_name.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Location".to_string() + id="app-config-farm-location-field".to_string() + > + <input + id="app-config-farm-location" + class="input-base" + type="text" + placeholder="City or region".to_string() + prop:value=move || farmer_location.get() + on:input=move |ev| { + farmer_location.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Products growing".to_string() + id="app-config-farm-products-field".to_string() + hint="Press enter to add items".to_string() + > + <input + id="app-config-farm-products-input" + class="input-base" + type="text" + placeholder="Add a product".to_string() + prop:value=move || farmer_products_input.get() + on:keydown=move |ev: KeyboardEvent| { + if ev.key() == "Enter" || ev.key() == "," { + ev.prevent_default(); + add_farmer_product(); + } + } + on:input=move |ev| { + farmer_products_input.set(event_target_value(&ev)); + } + /> + <RadrootsAppUiChips id="app-config-farm-products".to_string()> + <For + each=move || farmer_products.get() + key=|value| value.clone() + children=move |value| { + let remove_value = value.clone(); + view! { + <RadrootsAppUiChip + label=value.clone() + active=true + on_click=Callback::new(move |_| { + farmer_products.update(|items| { + items.retain(|item| item != &remove_value); + }); + }) + /> + } + } + /> + </RadrootsAppUiChips> + </RadrootsAppUiFormField> + </div> + }.into_any(), + Some(RadrootsAppRole::Individual) => view! { + <div + id="app-config-role-individual" + class="flex flex-col gap-4" + > + <RadrootsAppUiFormField + label="Name".to_string() + id="app-config-individual-name-field".to_string() + > + <input + id="app-config-individual-name" + class="input-base" + type="text" + placeholder="Your name".to_string() + prop:value=move || individual_name.get() + on:input=move |ev| { + individual_name.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Location".to_string() + id="app-config-individual-location-field".to_string() + > + <input + id="app-config-individual-location" + class="input-base" + type="text" + placeholder="City or region".to_string() + prop:value=move || individual_location.get() + on:input=move |ev| { + individual_location.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Products interested in".to_string() + id="app-config-individual-products-field".to_string() + hint="Press enter to add items".to_string() + > + <input + id="app-config-individual-products-input" + class="input-base" + type="text" + placeholder="Add a product".to_string() + prop:value=move || individual_products_input.get() + on:keydown=move |ev: KeyboardEvent| { + if ev.key() == "Enter" || ev.key() == "," { + ev.prevent_default(); + add_individual_product(); + } + } + on:input=move |ev| { + individual_products_input.set(event_target_value(&ev)); + } + /> + <RadrootsAppUiChips id="app-config-individual-products".to_string()> + <For + each=move || individual_products.get() + key=|value| value.clone() + children=move |value| { + let remove_value = value.clone(); + view! { + <RadrootsAppUiChip + label=value.clone() + active=true + on_click=Callback::new(move |_| { + individual_products.update(|items| { + items.retain(|item| item != &remove_value); + }); + }) + /> + } + } + /> + </RadrootsAppUiChips> + </RadrootsAppUiFormField> + </div> + }.into_any(), + Some(RadrootsAppRole::Business) => view! { + <div + id="app-config-role-business" + class="flex flex-col gap-4" + > + <RadrootsAppUiFormField + label="Business name".to_string() + id="app-config-business-name-field".to_string() + > + <input + id="app-config-business-name" + class="input-base" + type="text" + placeholder="Business name".to_string() + prop:value=move || business_name.get() + on:input=move |ev| { + business_name.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Location".to_string() + id="app-config-business-location-field".to_string() + > + <input + id="app-config-business-location" + class="input-base" + type="text" + placeholder="City or region".to_string() + prop:value=move || business_location.get() + on:input=move |ev| { + business_location.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Type of operations".to_string() + id="app-config-business-operations-field".to_string() + hint="What do you coordinate or purchase?".to_string() + > + <textarea + id="app-config-business-operations" + class="textarea-base" + rows="3" + placeholder="Tell us about your operations".to_string() + prop:value=move || business_operations.get() + on:input=move |ev| { + business_operations.set(event_target_value(&ev)); + } + ></textarea> + </RadrootsAppUiFormField> + </div> + }.into_any(), + None => view! { + <p class="text-sm text-ly1-gl-label/80"> + {"Select a role to continue."} + </p> + }.into_any(), + }} + </section> + }.into_any(), + RadrootsAppConfigStep::Preferences => view! { + <section + id="app-config-preferences" + class="app-view app-view-enter flex flex-col w-full gap-5" + > + <RadrootsAppUiFormField + label="Notifications".to_string() + id="app-config-preferences-notifications".to_string() + > + <div class="flex flex-col gap-3"> + <label + id="app-config-notifications-orders" + class="flex items-center justify-between gap-4 rounded-touch border border-ly1-edge/60 bg-ly1 px-3 py-2 text-sm text-ly1-gl" + > + <span>{"Order updates"}</span> + <input + type="checkbox" + class="h-4 w-4 accent-[hsl(var(--ly1-gl))]" + prop:checked=move || notifications_orders.get() + on:change=move |ev| { + notifications_orders.set(event_target_checked(&ev)); + } + /> + </label> + <label + id="app-config-notifications-messages" + class="flex items-center justify-between gap-4 rounded-touch border border-ly1-edge/60 bg-ly1 px-3 py-2 text-sm text-ly1-gl" + > + <span>{"Messages"}</span> + <input + type="checkbox" + class="h-4 w-4 accent-[hsl(var(--ly1-gl))]" + prop:checked=move || notifications_messages.get() + on:change=move |ev| { + notifications_messages.set(event_target_checked(&ev)); + } + /> + </label> + </div> + </RadrootsAppUiFormField> + <RadrootsAppUiFormField + label="Payment method".to_string() + id="app-config-payment-method".to_string() + hint="Optional".to_string() + > + <input + id="app-config-payment-method-input" + class="input-base" + type="text" + placeholder="e.g. Cash, card, or invoice".to_string() + prop:value=move || payment_method.get() + on:input=move |ev| { + payment_method.set(event_target_value(&ev)); + } + /> + </RadrootsAppUiFormField> + </section> + }.into_any(), + }} </section> + <footer + id="app-config-actions" + class="z-10 absolute bottom-4 left-0 flex flex-col w-full justify-center items-center se-compact:bottom-0" + > + {move || { + let validation = config_validation(); + let continue_disabled = !validation.can_continue; + let continue_label = if matches!(config_step.get(), RadrootsAppConfigStep::Preferences) { + "Finish".to_string() + } else { + "Continue".to_string() + }; + let back_label = "Back".to_string(); + let continue_action = RadrootsAppUiButtonLayoutAction { + label: continue_label, + disabled: continue_disabled, + loading: false, + on_click: advance_step_click.clone(), + class: None, + class_label: None, + style: None, + }; + let back_action = RadrootsAppUiButtonLayoutBackAction { + visible: validation.can_back, + label: Some(back_label), + disabled: false, + on_click: rewind_step.clone(), + compact: false, + }; + view! { + <RadrootsAppUiButtonLayoutPair + continue_action=continue_action + back=back_action + /> + }.into_any() + }} + </footer> </main> } }