diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-10-23 01:36:39 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-10-23 01:36:39 +0200 |
| commit | 7bff22756beec82b4a1470e2d325b706dc56e5f2 (patch) | |
| tree | 1566965125cfd5fbd73d654e9ee6ca8256301411 /src | |
| parent | feat(form): Re-write the form macro as a proc macro (diff) | |
| download | web-client-7bff22756beec82b4a1470e2d325b706dc56e5f2.zip | |
feat(buy): Provide basic buy interface
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 56 | ||||
| -rw-r--r-- | src/components/async_fetch.rs | 67 | ||||
| -rw-r--r-- | src/components/banner.rs | 17 | ||||
| -rw-r--r-- | src/components/buy.rs | 212 | ||||
| -rw-r--r-- | src/components/input_placeholder.rs | 154 | ||||
| -rw-r--r-- | src/components/mod.rs | 1 | ||||
| -rw-r--r-- | src/components/product_overview.rs | 8 | ||||
| -rw-r--r-- | src/components/select_placeholder.rs | 40 |
8 files changed, 457 insertions, 98 deletions
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<Product, .await .map_err(Into::<Error>::into) } +pub(crate) async fn get_product_by_name( + name: String, +) -> Result< + Product, + rocie_client::apis::Error<rocie_client::apis::api_get_product_api::ProductByNameError>, +> { + let config = expect_context::<Store<ConfigState>>(); + product_by_name(&config.config().read(), &name).await +} +pub(crate) async fn get_products_by_part_name(part_name: String) -> Result<Vec<Product>, Error> { + let config = expect_context::<Store<ConfigState>>(); + product_suggestion_by_name(&config.config().read(), &part_name) + .await + .map_err(Into::<Error>::into) +} pub(crate) async fn get_unit_by_id(unit_id: UnitId) -> Result<Unit, Error> { let config = expect_context::<Store<ConfigState>>(); unit_by_id(&config.config().read(), unit_id) .await .map_err(Into::<Error>::into) } +pub(crate) async fn get_unit_property_by_id( + unit_id: UnitPropertyId, +) -> Result<UnitProperty, Error> { + let config = expect_context::<Store<ConfigState>>(); + unit_property_by_id(&config.config().read(), unit_id) + .await + .map_err(Into::<Error>::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<Vec<Product>, Error> { let config = expect_context::<Store<ConfigState>>(); @@ -48,3 +86,17 @@ pub(crate) async fn get_products() -> Result<Vec<Product>, Error> { .await .map_err(Into::<Error>::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::<Error>::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 { </Transition> } }}; + ( + @map_error_in_producer + from_resource = $resource:ident + producer = |$bound_variable:pat_param| $producer:block + ) => {{ + use leptos::prelude::{ElementChild, Suspend, Transition}; + + leptos::view! { + <Transition fallback=|| { + view! { <p>"Loading..."</p> } + }> + {move || Suspend::new(async move { + $resource + .await + .map(|$bound_variable| $producer) + })} + </Transition> + } + }}; } pub(crate) use AsyncFetch; - -// #[component] -// pub fn AsyncFetch<P, V, T, Fut>( -// fetcher: impl Fn() -> Fut + 'static + Send + Sync, -// producer: P, -// ) -> impl IntoView -// where -// V: IntoView + 'static, -// P: Fn(T) -> V + 'static + Send + Sync, -// Fut: Future<Output = Result<T, Error>> + 'static, -// T: 'static, -// LocalResource<Result<T, Error>>: IntoFuture<Output = Result<T, Error>> + Send, -// { -// view! { -// <Transition fallback=|| { -// view! { <p>"Loading..."</p> } -// }> -// { || Suspend::new(async { -// let value_resource = LocalResource::new( || fetcher()); -// value_resource -// .await -// .map(|value| { -// producer(value) -// }) -// })} -// </Transition> -// } -// } 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<T>(mut text: T) -> impl IntoView +where + T: FnMut() -> String + Send + 'static, +{ + view! { + <p class="text-white rounded-lg m-2 p-2 bg-red-600"> + {move || text()} + </p> + } +} 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! { + <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" /> + + <Show when=move || on_submit_errored.get().is_some()> + <Banner text=move || on_submit_errored.get().expect("Should be some") /> + </Show> + + { + Form! { + on_submit = |barcode_number, amount| { + let config = expect_context::<Store<ConfigState>>(); + 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"); + } + + }); + }; + + <Input + name=barcode_number, + rust_type=u32, + html_type="number", + label="Barcode Number", + /> + + <Input + name=amount, + rust_type=u16, + html_type="number", + label="Amount" + /> + } + } + } +} + +#[component] +pub fn AssociateBarcode() -> impl IntoView { + let product_name_signal; + + let (show_units, show_units_set) = signal(false); + view! { <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" /> - {Form! { - on_submit = |product_barcode, amount, unit_id| { - info!("Got product barcode: {product_barcode} with amount: {amount}, {unit_id}"); - }; - - <Input - name=product_barcode, - rust_type=u32, - html_type="number", - label="Product Barcode" - /> - <Select - name=unit_id, - rust_type=Uuid, - label="Unit", - options=[ - ("Kilogram", Uuid::new_v4()), - ("Gram", Uuid::new_v4()) - ] - /> - <Input - name=amount, - rust_type=u16, - html_type="number", - label="Amount" - /> - }} + { + 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()); + }); + }; + + <Input + name=product_name, + rust_type=String, + html_type="text", + label="Product Name", + reactive=product_name_signal + auto_complete=generate_suggest_products + /> + + <Show + when=move || show_units.get(), + > + <Select + name=unit_id, + rust_type=Uuid, + label="Unit", + options=AsyncResource! { + ( + product_name: Option<String> = product_name_signal(), + show_units_set: WriteSignal<bool> = show_units_set + ) -> Result<Vec<(String, String)>, leptos::error::Error> { + let units = product_unit_fetcher(product_name).await?; + + show_units_set.set(units.is_some()); + if let Some(units) = units { + Ok( + units + .into_iter() + .map(|unit| (unit.full_name_singular, unit.id.to_string())) + .collect() + ) + } else { + Ok(vec![]) + } + } + }, + /> + </Show> + + <Input + name=amount, + rust_type=u16, + html_type="number", + label="Amount" + /> + } + } + } +} + +async fn generate_suggest_products( + optional_product_name: Option<String>, +) -> Result<Option<Vec<String>>, 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<String>, +) -> Result<Option<Vec<Unit>>, leptos::error::Error> { + if let Some(product_name) = optinal_product_name + && !product_name.is_empty() + { + let value: Option<Product> = { + 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<Input>, #[prop(default = None)] initial_value: Option<String>, + #[prop(default = None)] reactive: Option<WriteSignal<Option<String>>>, + #[prop(default = None)] auto_complete: Option< + LocalResource<Result<Option<Vec<String>>, Error>>, + >, ) -> impl IntoView { let id = get_id(); + let (autocomplete_signal, autocomplete_set) = signal(String::new()); + view! { <div class="relative h-14"> <input id=id.to_string() type=input_type + autocomplete="off" class="\ absolute \ bottom-0 \ @@ -32,7 +45,8 @@ pub fn InputPlaceholder( border-gray-200 \ focus:outline-none \ h-10 \ - peer \ + peer/input \ + group/input \ placeholder-transparent \ rounded-t-lg \ text-gray-900 \ @@ -41,16 +55,23 @@ pub fn InputPlaceholder( placeholder="sentinel value" node_ref=node_ref value=initial_value + on:input:target=move |ev| { + if let Some(signal) = reactive { + signal.set(Some(ev.target().value())); + autocomplete_set.set(ev.target().value()); + } + } + prop:value=autocomplete_signal /> // TODO: Reference `var(--tw-border-2)` instead of the `2 px` <2025-10-01> - <div class=" + <div class="\ absolute \ bottom-0 \ h-[2px] \ w-full \ bg-gray-300 \ - peer-focus:bg-indigo-600 \ + peer-focus/input:bg-indigo-600 \ " /> <label @@ -62,18 +83,125 @@ pub fn InputPlaceholder( text-gray-700 \ text-sm \ transition-all \ - peer-focus:bottom-10 \ - peer-focus:left-0 \ - peer-focus:text-gray-700 \ - peer-focus:text-sm \ - peer-placeholder-shown:text-base \ - peer-placeholder-shown:text-gray-400 \ - peer-placeholder-shown:bottom-2 \ - peer-placeholder-shown:left-2 \ + peer-focus/input:bottom-10 \ + peer-focus/input:left-0 \ + peer-focus/input:text-gray-700 \ + peer-focus/input:text-sm \ + peer-placeholder-shown/input:text-base \ + peer-placeholder-shown/input:text-gray-400 \ + peer-placeholder-shown/input:bottom-2 \ + peer-placeholder-shown/input:left-2 \ " > {label} </label> + + <Show + when=move || { + !autocomplete_signal.get().is_empty() + } + fallback=move || () + > + <div class="\ + absolute \ + top-0 \ + left-0 \ + invisible \ + peer-focus/input:visible \ + in-focus:visible \ + "> + <div class="\ + flex \ + flex-row \ + g-0 \ + "> + // TODO: Reference `var(--tw-border-8)` instead of the `8 px` <2025-10-11> + <div class="w-[8px] h-full" /> + <div class="\ + flex \ + flex-col \ + g-0 \ + "> + <div class="h-14 w-full peer/div" /> + <div class="\ + bg-white \ + shadow \ + outline \ + outline-black/5 \ + rounded-lg \ + z-50 \ + p-2 \ + visible \ + "> + {move || { + auto_complete + .map(|auto_complete| { + provide_auto_completion( + auto_complete, + autocomplete_set, + reactive + ) + }) + }} + </div> + </div> + </div> + </div> + </Show> </div> } } + +fn provide_auto_completion( + auto_complete: LocalResource<Result<Option<Vec<String>>, Error>>, + autocomplete_set: WriteSignal<String>, + reactive: Option<WriteSignal<Option<String>>>, +) -> impl IntoView { + match auto_complete.get() { + Some(resource_result) => match resource_result { + Ok(resource_fetch) => resource_fetch.map(|_| { + view! { + <div class="flex flex-col g-1"> + {move || { + auto_complete + .get() + .expect("Worked before") + .unwrap() + .unwrap() + .into_iter() + .map(|item| { + let item2 = item.clone(); + view! { + <button + type="button" + on:click=move |_| { + autocomplete_set.set(item2.clone()); + reactive + .expect( + "Should be set, \ + when autocomplete is used") + .set(Some(item2.clone())); + + info!("Set autocomplete to {item2}."); + } + > + {item} + </button> + } + }) + .collect_view() + }} + </div> + } + }), + Err(err) => { + error!( + "Error while loading \ + autocompletion: {err}" + ); + None + } + }, + None => None, + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 1ee37d5..ca2ac10 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -7,6 +7,7 @@ pub mod form; pub mod icon_p; pub mod input_placeholder; pub mod select_placeholder; +pub mod banner; // Specific pub mod buy; diff --git a/src/components/product_overview.rs b/src/components/product_overview.rs index d86c04d..777baef 100644 --- a/src/components/product_overview.rs +++ b/src/components/product_overview.rs @@ -19,8 +19,9 @@ pub fn ProductOverview() -> impl IntoView { (view! { <IconP icon=icondata_io::IoStorefront text="Buy" /> }, "buy"), ] > - {AsyncFetch!( - fetcher = {get_products()} + { + AsyncFetch! { + fetcher = {get_products()} producer = |products| { let products_num = products.len(); let plural_s = if products_num == 1 { "" } else { "s" }; @@ -37,7 +38,8 @@ pub fn ProductOverview() -> impl IntoView { </p> } } - )} + } + } </Container> } } diff --git a/src/components/select_placeholder.rs b/src/components/select_placeholder.rs index 947931c..2e0f783 100644 --- a/src/components/select_placeholder.rs +++ b/src/components/select_placeholder.rs @@ -1,37 +1,24 @@ use leptos::{ - IntoView, - attr::{AttributeValue, IntoAttributeValue}, - component, + IntoView, component, + error::Error, html::Select, prelude::{ ClassAttribute, CollectView, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute, }, + server::LocalResource, view, }; -use crate::components::get_id; +use crate::components::{async_fetch::AsyncFetch, get_id}; #[component] -pub fn SelectPlaceholder<T>( +pub fn SelectPlaceholder( label: &'static str, node_ref: NodeRef<Select>, - options: Vec<(&'static str, T)>, -) -> impl IntoView -where - T: IntoAttributeValue, - <T as IntoAttributeValue>::Output: Send + AttributeValue, -{ + options: LocalResource<Result<Vec<(String, String)>, Error>>, +) -> impl IntoView { let id = get_id(); - let options = options - .into_iter() - .map(|(label, value)| { - view! { - <option value=value>{label}</option> - } - }) - .collect_view(); - view! { <div class="relative h-14"> <select @@ -54,7 +41,18 @@ where " node_ref=node_ref > - {options} + {move || AsyncFetch! { + @map_error_in_producer + from_resource = options + producer = |options| { + options + .into_iter() + .map(|(label, value)| { + view! { <option value=value>{label}</option> } + }) + .collect_view() + } + }} </select> // TODO: Reference `var(--tw-border-2)` instead of the `2 px` <2025-10-01> |
