summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-10-23 01:36:39 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-10-23 01:36:39 +0200
commit7bff22756beec82b4a1470e2d325b706dc56e5f2 (patch)
tree1566965125cfd5fbd73d654e9ee6ca8256301411 /src
parentfeat(form): Re-write the form macro as a proc macro (diff)
downloadweb-client-7bff22756beec82b4a1470e2d325b706dc56e5f2.zip
feat(buy): Provide basic buy interface
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs56
-rw-r--r--src/components/async_fetch.rs67
-rw-r--r--src/components/banner.rs17
-rw-r--r--src/components/buy.rs212
-rw-r--r--src/components/input_placeholder.rs154
-rw-r--r--src/components/mod.rs1
-rw-r--r--src/components/product_overview.rs8
-rw-r--r--src/components/select_placeholder.rs40
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>