From 7bff22756beec82b4a1470e2d325b706dc56e5f2 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Thu, 23 Oct 2025 01:36:39 +0200 Subject: feat(buy): Provide basic buy interface --- src/api/mod.rs | 56 ++++++++- src/components/async_fetch.rs | 67 ++++++----- src/components/banner.rs | 17 +++ src/components/buy.rs | 212 ++++++++++++++++++++++++++++++----- src/components/input_placeholder.rs | 154 ++++++++++++++++++++++--- src/components/mod.rs | 1 + src/components/product_overview.rs | 8 +- src/components/select_placeholder.rs | 40 ++++--- 8 files changed, 457 insertions(+), 98 deletions(-) create mode 100644 src/components/banner.rs (limited to 'src') diff --git a/src/api/mod.rs b/src/api/mod.rs index 8b9e77d..3879223 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,10 +6,16 @@ use reactive_stores::Store; use rocie_client::{ apis::{ api_get_inventory_api::amount_by_id, - api_get_product_api::{product_by_id, products}, + api_get_product_api::{ + product_by_id, product_by_name, product_suggestion_by_name, products, + }, api_get_unit_api::unit_by_id, + api_get_unit_property_api::unit_property_by_id, + api_set_barcode_api::buy_barcode, configuration::Configuration, + }, + models::{ + BarcodeId, Product, ProductAmount, ProductId, Unit, UnitId, UnitProperty, UnitPropertyId, }, - models::{Product, ProductAmount, ProductId, Unit, UnitId}, }; use crate::{ConfigState, ConfigStateStoreFields}; @@ -26,12 +32,36 @@ pub(crate) async fn get_product_by_id(product_id: ProductId) -> Result::into) } +pub(crate) async fn get_product_by_name( + name: String, +) -> Result< + Product, + rocie_client::apis::Error, +> { + let config = expect_context::>(); + product_by_name(&config.config().read(), &name).await +} +pub(crate) async fn get_products_by_part_name(part_name: String) -> Result, Error> { + let config = expect_context::>(); + product_suggestion_by_name(&config.config().read(), &part_name) + .await + .map_err(Into::::into) +} pub(crate) async fn get_unit_by_id(unit_id: UnitId) -> Result { let config = expect_context::>(); unit_by_id(&config.config().read(), unit_id) .await .map_err(Into::::into) } +pub(crate) async fn get_unit_property_by_id( + unit_id: UnitPropertyId, +) -> Result { + let config = expect_context::>(); + unit_property_by_id(&config.config().read(), unit_id) + .await + .map_err(Into::::into) +} + pub(crate) async fn get_full_product_by_id( id: ProductId, ) -> Result<(Product, ProductAmount, Unit), Error> { @@ -41,6 +71,14 @@ pub(crate) async fn get_full_product_by_id( Ok::<_, Error>((product, amount, unit)) } +pub(crate) async fn get_product_unit_by_id( + id: ProductId, +) -> Result<(Product, UnitProperty), Error> { + let product = get_product_by_id(id).await?; + let unit = get_unit_property_by_id(product.unit_property).await?; + + Ok::<_, Error>((product, unit)) +} pub(crate) async fn get_products() -> Result, Error> { let config = expect_context::>(); @@ -48,3 +86,17 @@ pub(crate) async fn get_products() -> Result, Error> { .await .map_err(Into::::into) } + +pub(crate) async fn buy_barcode_wrapper( + config: &Configuration, + barcode_number: u32, +) -> Result<(), Error> { + buy_barcode( + config, + BarcodeId { + value: barcode_number, + }, + ) + .await + .map_err(Into::::into) +} diff --git a/src/components/async_fetch.rs b/src/components/async_fetch.rs index 7105c6f..f24e3a5 100644 --- a/src/components/async_fetch.rs +++ b/src/components/async_fetch.rs @@ -1,5 +1,23 @@ +macro_rules! AsyncResource { + ( + ( + $( + $input_name:ident : $input_type:ty = $input:expr + ),* + ) -> $output:ty $fetcher:block + ) => {{ + async fn fetcher($($input_name : $input_type),*) -> $output $fetcher + + leptos::prelude::LocalResource::new(move || fetcher($($input),*)) + }} +} +pub(crate) use AsyncResource; + macro_rules! AsyncFetch { - (fetcher = $fetcher:block producer = |$bound_variable:pat_param| $producer:block) => {{ + ( + fetcher = $fetcher:block + producer = |$bound_variable:pat_param| $producer:block + ) => {{ use leptos::{ prelude::{ElementChild, LocalResource, Suspend, Transition}, view, @@ -18,33 +36,24 @@ macro_rules! AsyncFetch { } }}; + ( + @map_error_in_producer + from_resource = $resource:ident + producer = |$bound_variable:pat_param| $producer:block + ) => {{ + use leptos::prelude::{ElementChild, Suspend, Transition}; + + leptos::view! { + "Loading..."

} + }> + {move || Suspend::new(async move { + $resource + .await + .map(|$bound_variable| $producer) + })} +
+ } + }}; } pub(crate) use AsyncFetch; - -// #[component] -// pub fn AsyncFetch( -// fetcher: impl Fn() -> Fut + 'static + Send + Sync, -// producer: P, -// ) -> impl IntoView -// where -// V: IntoView + 'static, -// P: Fn(T) -> V + 'static + Send + Sync, -// Fut: Future> + 'static, -// T: 'static, -// LocalResource>: IntoFuture> + Send, -// { -// view! { -// "Loading..."

} -// }> -// { || Suspend::new(async { -// let value_resource = LocalResource::new( || fetcher()); -// value_resource -// .await -// .map(|value| { -// producer(value) -// }) -// })} -//
-// } -// } diff --git a/src/components/banner.rs b/src/components/banner.rs new file mode 100644 index 0000000..acaaf62 --- /dev/null +++ b/src/components/banner.rs @@ -0,0 +1,17 @@ +use leptos::{ + IntoView, component, + prelude::{ClassAttribute, ElementChild}, + view, +}; + +#[component] +pub fn Banner(mut text: T) -> impl IntoView +where + T: FnMut() -> String + Send + 'static, +{ + view! { +

+ {move || text()} +

+ } +} diff --git a/src/components/buy.rs b/src/components/buy.rs index 6d9402e..cb4cff4 100644 --- a/src/components/buy.rs +++ b/src/components/buy.rs @@ -1,41 +1,193 @@ -use leptos::{IntoView, component, view}; +use leptos::{ + IntoView, component, + prelude::{Get, Read, Show, WriteSignal, expect_context, signal}, + task::spawn_local, + view, +}; +use leptos_router::{NavigateOptions, hooks::use_navigate}; use log::info; -use rocie_client::models::UnitId; +use reactive_stores::Store; +use rocie_client::{ + apis::Error, + models::{Product, Unit}, +}; use uuid::Uuid; -use crate::components::{form::Form, site_header::SiteHeader}; +use crate::{ + ConfigState, ConfigStateStoreFields, + api::{ + buy_barcode_wrapper, get_product_by_name, get_product_unit_by_id, + get_products_by_part_name, get_unit_by_id, + }, + components::{async_fetch::AsyncResource, banner::Banner, form::Form, site_header::SiteHeader}, +}; #[component] pub fn Buy() -> impl IntoView { + let (on_submit_errored, on_submit_errored_set) = signal(None); + + view! { + + + + + + + { + Form! { + on_submit = |barcode_number, amount| { + let config = expect_context::>(); + let config = config.config().read(); + + spawn_local(async move { + if let Err(err) = buy_barcode_wrapper(&config, barcode_number).await { + let error = format!("Error in form on-submit for barcode `{barcode_number}`: {err}"); + on_submit_errored_set.set(Some(error)); + } else { + on_submit_errored_set.set(None); + + info!("Bought barcode {barcode_number} {amount} times"); + } + + }); + }; + + + + + } + } + } +} + +#[component] +pub fn AssociateBarcode() -> impl IntoView { + let product_name_signal; + + let (show_units, show_units_set) = signal(false); + view! { - {Form! { - on_submit = |product_barcode, amount, unit_id| { - info!("Got product barcode: {product_barcode} with amount: {amount}, {unit_id}"); - }; - - - - }} + { + Form! { + on_submit = |product_name, amount, unit_id| { + spawn_local(async move { + let navigate = use_navigate(); + + info!("Got product barcode: {product_name} with amount: {amount}, and {unit_id}"); + + navigate("/", NavigateOptions::default()); + }); + }; + + + + + + } + } + } +} + +async fn generate_suggest_products( + optional_product_name: Option, +) -> Result>, leptos::error::Error> { + if let Some(product_name) = optional_product_name + && !product_name.is_empty() + { + let products = get_products_by_part_name(product_name).await?; + Ok(Some(products.into_iter().map(|prod| prod.name).collect())) + } else { + Ok(None) + } +} + +async fn product_unit_fetcher( + optinal_product_name: Option, +) -> Result>, leptos::error::Error> { + if let Some(product_name) = optinal_product_name + && !product_name.is_empty() + { + let value: Option = { + match get_product_by_name(product_name).await { + Ok(ok) => Ok::<_, leptos::error::Error>(Some(ok)), + Err(err) => match err { + Error::ResponseError(ref response_content) => { + match response_content.status.as_u16() { + 404 => Ok(None), + _ => Err(err.into()), + } + } + err => Err(err.into()), + }, + }? + }; + + if let Some(value) = value { + let (_, unit_property) = get_product_unit_by_id(value.id).await?; + + let mut units = Vec::with_capacity(unit_property.units.len()); + for unit_id in unit_property.units { + units.push(get_unit_by_id(unit_id).await?); + } + + Ok(Some(units)) + } else { + Ok(None) + } + } else { + Ok(None) } } diff --git a/src/components/input_placeholder.rs b/src/components/input_placeholder.rs index aeef838..99b3196 100644 --- a/src/components/input_placeholder.rs +++ b/src/components/input_placeholder.rs @@ -1,27 +1,40 @@ use leptos::{ IntoView, component, + error::Error, html::Input, - prelude::{ClassAttribute, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute}, + prelude::{ + ClassAttribute, CollectView, ElementChild, Get, GlobalAttributes, LocalResource, NodeRef, + NodeRefAttribute, OnAttribute, OnTargetAttribute, PropAttribute, Set, Show, WriteSignal, + signal, + }, view, }; +use log::{error, info}; use crate::components::get_id; - #[component] +#[expect(clippy::too_many_lines)] pub fn InputPlaceholder( input_type: &'static str, label: &'static str, node_ref: NodeRef, #[prop(default = None)] initial_value: Option, + #[prop(default = None)] reactive: Option>>, + #[prop(default = None)] auto_complete: Option< + LocalResource>, Error>>, + >, ) -> impl IntoView { let id = get_id(); + let (autocomplete_signal, autocomplete_set) = signal(String::new()); + view! {
// TODO: Reference `var(--tw-border-2)` instead of the `2 px` <2025-10-01> -
+ + +