summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs221
-rw-r--r--src/components/async_fetch.rs12
-rw-r--r--src/components/buy.rs1
-rw-r--r--src/components/catch_errors.rs40
-rw-r--r--src/components/checkbox_placeholder.rs54
-rw-r--r--src/components/container.rs52
-rw-r--r--src/components/inventory.rs4
-rw-r--r--src/components/login_wall.rs42
-rw-r--r--src/components/mod.rs7
-rw-r--r--src/components/product_overview.rs19
-rw-r--r--src/components/product_parent_overview.rs37
-rw-r--r--src/components/recipies.rs20
-rw-r--r--src/components/textarea_placeholder.rs60
-rw-r--r--src/components/unit_overview.rs37
-rw-r--r--src/lib.rs60
-rw-r--r--src/pages/associate_barcode.rs96
-rw-r--r--src/pages/buy.rs63
-rw-r--r--src/pages/create_product.rs109
-rw-r--r--src/pages/create_product_parent.rs126
-rw-r--r--src/pages/create_recipe.rs108
-rw-r--r--src/pages/home.rs46
-rw-r--r--src/pages/inventory.rs68
-rw-r--r--src/pages/login.rs81
-rw-r--r--src/pages/mod.rs83
-rw-r--r--src/pages/not_found.rs7
-rw-r--r--src/pages/product.rs52
-rw-r--r--src/pages/products.rs68
-rw-r--r--src/pages/provision.rs93
-rw-r--r--src/pages/recipe.rs344
-rw-r--r--src/pages/recipies.rs74
-rw-r--r--src/pages/units.rs78
31 files changed, 1932 insertions, 230 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 3bc870c..eb9ca3a 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -1,20 +1,38 @@
 use leptos::error::Error;
 use rocie_client::{
     apis::{
-        api_get_inventory_api::amount_by_id,
-        api_get_product_api::{
-            product_by_id, product_by_name, product_suggestion_by_name, products_in_storage,
-            products_registered,
+        api_get_auth_inventory_api::amount_by_id,
+        api_get_auth_product_api::{
+            product_by_id, product_by_name, product_suggestion_by_name,
+            products_by_product_parent_id_direct, products_by_product_parent_id_indirect,
+            products_in_storage, products_registered, products_without_product_parent,
         },
-        api_get_unit_api::unit_by_id,
-        api_get_unit_property_api::{unit_properties, unit_property_by_id},
-        api_set_barcode_api::buy_barcode,
-        api_set_product_api::{associate_barcode, register_product},
+        api_get_auth_product_parent_api::{
+            product_parents, product_parents_toplevel, product_parents_under,
+        },
+        api_get_auth_recipe_api::{
+            recipe_by_id, recipe_by_name, recipes, recipes_by_recipe_parent_id_direct,
+            recipes_by_recipe_parent_id_indirect, recipes_without_recipe_parent,
+        },
+        api_get_auth_recipe_parent_api::{
+            recipe_parents, recipe_parents_toplevel, recipe_parents_under,
+        },
+        api_get_auth_unit_api::{unit_by_id, units, units_by_property_id},
+        api_get_auth_unit_property_api::{unit_properties, unit_property_by_id},
+        api_get_no_auth_state_api::{can_be_provisioned, is_logged_in},
+        api_set_auth_barcode_api::buy_barcode,
+        api_set_auth_product_api::{associate_barcode, register_product},
+        api_set_auth_product_parent_api::register_product_parent,
+        api_set_auth_recipe_api::add_recipe,
+        api_set_auth_recipe_parent_api::register_recipe_parent,
+        api_set_no_auth_user_api::{login, provision},
         configuration::Configuration,
     },
     models::{
-        Barcode, BarcodeId, Product, ProductAmount, ProductId, ProductStub, Unit, UnitId,
-        UnitProperty, UnitPropertyId,
+        Barcode, BarcodeId, LoginInfo, Product, ProductAmount, ProductId, ProductParent,
+        ProductParentId, ProductParentStub, ProductStub, ProvisionInfo, Recipe, RecipeId,
+        RecipeParent, RecipeParentId, RecipeParentStub, RecipeStub, Unit, UnitId, UnitProperty,
+        UnitPropertyId, UserId, UserStub,
     },
 };
 
@@ -89,22 +107,179 @@ macro_rules! mk_wrapper {
     }
 }
 
-mk_wrapper!(product_by_id(product_id: ProductId) -> Product as product_by_id_wrapped);
+mk_wrapper!(
+    is_logged_in() -> bool
+    as is_logged_in_wrapped
+);
+mk_wrapper!(
+    can_be_provisioned() -> bool
+    as can_be_provisioned_wrapped
+);
+
+mk_wrapper!(
+    @external_config
+    login(&config, login_info: LoginInfo) -> ()
+    as login_external_wrapped
+);
+mk_wrapper!(
+    @external_config
+    provision(&config, provsion_info: ProvisionInfo) -> UserId
+    as provision_external_wrapped
+);
+
+mk_wrapper!(
+    product_by_id(product_id: ProductId) -> Product
+    as product_by_id_wrapped
+);
+
+mk_wrapper!(
+    product_by_name(name: &str) -> Product
+    as product_by_name_wrapped
+);
+mk_wrapper!(
+    @treat_404_as_None
+    product_by_name(name: &str) -> Option<Product>
+    as product_by_name_404_wrapped
+);
+mk_wrapper!(
+    @external_config
+    product_by_name(&config, name: &str) -> Product
+    as product_by_name_external_wrapped
+);
+
+mk_wrapper!(
+    product_suggestion_by_name(part_name: &str) -> Vec<Product>
+    as product_suggestion_by_name_wrapped
+);
+
+mk_wrapper!(
+    units() -> Vec<Unit>
+    as units_wrapped
+);
+mk_wrapper!(
+    units_by_property_id(id: UnitPropertyId) -> Vec<Unit>
+    as units_by_property_id_wrapped
+);
+mk_wrapper!(
+    unit_by_id(unit_id: UnitId) -> Unit
+    as unit_by_id_wrapped
+);
+mk_wrapper!(
+    unit_property_by_id(unit_id: UnitPropertyId) -> UnitProperty
+    as unit_property_by_id_wrapped
+);
+mk_wrapper!(
+    unit_properties() -> Vec<UnitProperty>
+    as unit_properties_wrapped
+);
 
-mk_wrapper!(@treat_404_as_None product_by_name(name: &str) -> Option<Product> as product_by_name_404_wrapped);
-mk_wrapper!(@external_config product_by_name(&config, name: &str) -> Product as product_by_name_external_wrapped);
+mk_wrapper!(
+    @treat_404_as_None
+    amount_by_id(product_id: ProductId) -> Option<ProductAmount>
+    as amount_by_id_404_wrapped
+);
 
-mk_wrapper!(product_suggestion_by_name(part_name: &str) -> Vec<Product> as product_suggestion_by_name_wrapped);
+mk_wrapper!(
+    products_registered() -> Vec<Product>
+    as products_registered_wrapped
+);
+mk_wrapper!(
+    products_in_storage() -> Vec<Product>
+    as products_in_storage_wrapped
+);
+mk_wrapper!(
+    products_without_product_parent() -> Vec<Product>
+    as products_without_product_parent_wrapped
+);
+mk_wrapper!(
+    products_by_product_parent_id_indirect(product_parent_id: ProductParentId) -> Vec<Product>
+    as products_by_product_parent_id_indirect_wrapped
+);
+mk_wrapper!(
+    products_by_product_parent_id_direct(product_parent_id: ProductParentId) -> Vec<Product>
+    as products_by_product_parent_id_direct_wrapped
+);
 
-mk_wrapper!(unit_by_id(unit_id: UnitId) -> Unit as unit_by_id_wrapped);
-mk_wrapper!(unit_property_by_id(unit_id: UnitPropertyId) -> UnitProperty as unit_property_by_id_wrapped);
-mk_wrapper!(unit_properties() -> Vec<UnitProperty> as unit_properties_wrapped);
+mk_wrapper!(
+    @external_config
+    register_product_parent(&config, product_parent_stub: ProductParentStub) -> ProductParentId
+    as register_product_parent_external_wrapped
+);
+mk_wrapper!(
+    product_parents_toplevel() -> Vec<ProductParent>
+    as product_parents_toplevel_wrapped
+);
+mk_wrapper!(
+    @treat_404_as_None
+    product_parents_under(id: ProductParentId) -> Option<Vec<ProductParent>>
+    as product_parents_under_404_wrapped
+);
+mk_wrapper!(
+    product_parents() -> Vec<ProductParent>
+    as product_parents_wrapped
+);
 
-mk_wrapper!(amount_by_id(product_id: ProductId) -> ProductAmount as amount_by_id_wrapped);
+mk_wrapper!(
+    recipes() -> Vec<Recipe>
+    as recipes_wrapped
+);
+mk_wrapper!(
+    recipes_without_recipe_parent() -> Vec<Recipe>
+    as recipes_without_recipe_parent_wrapped
+);
+mk_wrapper!(
+    @external_config
+    add_recipe(&config, stub: RecipeStub) -> RecipeId
+    as add_recipe_external_wrapped
+);
+mk_wrapper!(
+    recipes_by_recipe_parent_id_indirect(recipe_parent_id: RecipeParentId) -> Vec<Recipe>
+    as recipes_by_recipe_parent_id_indirect_wrapped
+);
+mk_wrapper!(
+    recipes_by_recipe_parent_id_direct(recipe_parent_id: RecipeParentId) -> Vec<Recipe>
+    as recipes_by_recipe_parent_id_direct_wrapped
+);
+mk_wrapper!(
+    recipe_by_name(name: &str) -> Recipe
+    as recipe_by_name_wrapped
+);
+mk_wrapper!(
+    recipe_by_id(id: RecipeId) -> Recipe
+    as recipe_by_id_wrapped
+);
 
-mk_wrapper!(products_registered() -> Vec<Product> as products_registered_wrapped);
-mk_wrapper!(products_in_storage() -> Vec<Product> as products_in_storage_wrapped);
+mk_wrapper!(
+    @external_config
+    register_recipe_parent(&config, recipe_parent_stub: RecipeParentStub) -> RecipeParentId
+    as register_recipe_parent_external_wrapped
+);
+mk_wrapper!(
+    recipe_parents_toplevel() -> Vec<RecipeParent>
+    as recipe_parents_toplevel_wrapped
+);
+mk_wrapper!(
+    @treat_404_as_None
+    recipe_parents_under(id: RecipeParentId) -> Option<Vec<RecipeParent>>
+    as recipe_parents_under_404_wrapped
+);
+mk_wrapper!(
+    recipe_parents() -> Vec<RecipeParent>
+    as recipe_parents_wrapped
+);
 
-mk_wrapper!(@external_config buy_barcode(&config, barcode_number: BarcodeId, times: u32) -> () as buy_barcode_external_wrapped);
-mk_wrapper!(@external_config register_product(&config, product_stub: ProductStub) -> ProductId as register_product_external_wrapped);
-mk_wrapper!(@external_config associate_barcode(&config, id: ProductId, barcode: Barcode) -> () as associate_barcode_external_wrapped);
+mk_wrapper!(
+    @external_config
+    buy_barcode(&config, barcode_number: BarcodeId, times: u32) -> ()
+    as buy_barcode_external_wrapped
+);
+mk_wrapper!(
+    @external_config
+    register_product(&config, product_stub: ProductStub) -> ProductId
+    as register_product_external_wrapped
+);
+mk_wrapper!(
+    @external_config
+    associate_barcode(&config, id: ProductId, barcode: Barcode) -> ()
+    as associate_barcode_external_wrapped
+);
diff --git a/src/components/async_fetch.rs b/src/components/async_fetch.rs
index 7bf44a0..43469a7 100644
--- a/src/components/async_fetch.rs
+++ b/src/components/async_fetch.rs
@@ -37,11 +37,13 @@ macro_rules! AsyncFetch {
             <Transition fallback=|| {
                 view! { <p>"Loading..."</p> }
             }>
-                {move || Suspend::new(async move {
-                    $resource
-                        .await
-                        .map($producer)
-                })}
+                {
+                    Suspend::new(async move {
+                        $resource
+                            .await
+                            .map($producer)
+                    })
+                }
             </Transition>
         }
     }};
diff --git a/src/components/buy.rs b/src/components/buy.rs
index e69de29..8b13789 100644
--- a/src/components/buy.rs
+++ b/src/components/buy.rs
@@ -0,0 +1 @@
+
diff --git a/src/components/catch_errors.rs b/src/components/catch_errors.rs
new file mode 100644
index 0000000..d5a452d
--- /dev/null
+++ b/src/components/catch_errors.rs
@@ -0,0 +1,40 @@
+use leptos::{
+    IntoView, component,
+    error::ErrorBoundary,
+    prelude::{Children, ClassAttribute, CollectView, ElementChild, Get},
+    view,
+};
+
+use crate::components::site_header::SiteHeader;
+
+#[component]
+pub(crate) fn CatchErrors(children: Children) -> impl IntoView {
+    view! {
+        <ErrorBoundary fallback=|errors| {
+            view! {
+                <SiteHeader
+                    logo=icondata_io::IoRoseSharp
+                    back_location="/"
+                    name="Errors occurred"
+                />
+
+                <h1>"Uh oh! Something went wrong!"</h1>
+
+                <p>"Errors: "</p>
+                <ul class="flex flex-col gap-1">
+                    {move || {
+                        errors
+                            .get()
+                            .into_iter()
+                            .map(|(_, e)| {
+                                view! {
+                                    <li class="bg-gray-200 rounded-lg m-2 p-1">{e.to_string()}</li>
+                                }
+                            })
+                            .collect_view()
+                    }}
+                </ul>
+            }
+        }>{children()}</ErrorBoundary>
+    }
+}
diff --git a/src/components/checkbox_placeholder.rs b/src/components/checkbox_placeholder.rs
new file mode 100644
index 0000000..a1aaa0c
--- /dev/null
+++ b/src/components/checkbox_placeholder.rs
@@ -0,0 +1,54 @@
+use leptos::{
+    IntoView, component,
+    html::Input,
+    prelude::{ClassAttribute, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute},
+    view,
+};
+
+use crate::components::get_id;
+
+#[component]
+pub fn CheckboxPlaceholder(
+    label: &'static str,
+    node_ref: NodeRef<Input>,
+) -> impl IntoView {
+    let id = get_id();
+
+    view! {
+        <div class="relative h-14">
+            <input
+                id=id.to_string()
+                type="checkbox"
+                autocomplete="off"
+                class="\
+                absolute \
+                bottom-0 \
+                right-5 \
+                bg-gray-200 \
+                border-8 \
+                border-b-2 \
+                border-b-trasparent \
+                border-gray-200 \
+                focus:outline-none \
+                h-10 \
+                rounded-t-lg \
+                text-gray-900 \
+                "
+                node_ref=node_ref
+            />
+
+            <label
+                for=id.to_string()
+                class="\
+                bottom-10 \
+                absolute \
+                left-0 \
+                text-gray-700 \
+                text-sm \
+                "
+            >
+                {label}
+            </label>
+        </div>
+    }
+}
diff --git a/src/components/container.rs b/src/components/container.rs
index d6d2f03..3b56713 100644
--- a/src/components/container.rs
+++ b/src/components/container.rs
@@ -1,46 +1,38 @@
 use leptos::{
     IntoView, component,
-    prelude::{Children, ClassAttribute, ElementChild, OnAttribute},
+    prelude::{Children, ClassAttribute, ElementChild},
     view,
 };
-use leptos_router::{NavigateOptions, hooks::use_navigate};
+use leptos_router::components::A;
 
 #[component]
 pub fn Container(
-    header: impl IntoView,
-    buttons: Vec<(impl IntoView, &'static str)>,
+    header: impl IntoView + 'static,
+    buttons: Vec<(impl IntoView + 'static, &'static str)>,
     children: Children,
 ) -> impl IntoView {
     assert!(!buttons.is_empty());
 
-    let first_button_path = buttons.first().expect("Should have at least on button").1;
+    // TODO: Add the direct link to the first button back. <2026-02-15>
+    // let first_button_path = buttons.first().expect("Should have at least on button").1;
 
     view! {
-        <button
-            type="button"
-            on:click=|_| {
-                use_navigate()(first_button_path, NavigateOptions::default());
-            }
-        >
-            <div class="p-4 mt-4 mr-4 ml-4 md-2 text-justify rounded-lg border-gray-600 border">
-                <p class="text-lg text-bold">{header}</p>
-                {children()}
+        <div class="p-4 mt-4 mr-4 ml-4 md-2 text-justify rounded-lg border-gray-600 border">
+            <h2 class="text-lg text-bold">{header}</h2>
+            {children()}
 
-                <ul class="flex flex-row gap-1 pt-2 overflow-x-auto">
-                    {buttons
-                        .into_iter()
-                        .map(|(name, path)| {
-                            view! {
-                                <li class="bg-green-400/40 p-2 text-nowrap first:rounded-l-full last:rounded-r-full">
-                                    <button on:click=move |_| {
-                                        use_navigate()(path, NavigateOptions::default());
-                                    }>{name}</button>
-                                </li>
-                            }
-                        })
-                        .collect::<Vec<_>>()}
-                </ul>
-            </div>
-        </button>
+            <ul class="flex flex-row gap-1 pt-2 overflow-x-auto">
+                {buttons
+                    .into_iter()
+                    .map(|(name, path)| {
+                        view! {
+                            <li class="bg-green-400/40 p-2 text-nowrap first:rounded-l-full last:rounded-r-full">
+                                <A href=move || path.to_owned()>{name}</A>
+                            </li>
+                        }
+                    })
+                    .collect::<Vec<_>>()}
+            </ul>
+        </div>
     }
 }
diff --git a/src/components/inventory.rs b/src/components/inventory.rs
index 275dd0b..31b1c12 100644
--- a/src/components/inventory.rs
+++ b/src/components/inventory.rs
@@ -23,8 +23,8 @@ pub fn Inventory() -> impl IntoView {
                     producer = |products| {
                         let products_num = products.len();
                         let plural_s = if products_num == 1 { "" } else { "s" };
-                        let products_value = 2;
-                        let products_currency = "EUR";
+                        let products_value = -1;
+                        let products_currency = "TODO";
 
                         view! {
                             <p>
diff --git a/src/components/login_wall.rs b/src/components/login_wall.rs
new file mode 100644
index 0000000..fd5c64f
--- /dev/null
+++ b/src/components/login_wall.rs
@@ -0,0 +1,42 @@
+use leptos::{
+    IntoView, component,
+    error::Error,
+    prelude::{Children, IntoAny},
+    view,
+};
+use leptos_router::{NavigateOptions, hooks::use_navigate};
+
+use crate::{
+    api::{can_be_provisioned_wrapped, is_logged_in_wrapped},
+    components::async_fetch::{AsyncFetch, AsyncResource},
+};
+
+#[component]
+pub fn LoginWall(
+    back: impl Fn() -> String + Send + Sync + 'static,
+    children: Children,
+) -> impl IntoView {
+    view! {
+        {
+            AsyncFetch! {
+                @map_error_in_producer
+                from_resource = AsyncResource!(
+                    () -> Result<(bool, bool), Error> {
+                        Ok((can_be_provisioned_wrapped().await?, is_logged_in_wrapped().await?))
+                    }
+                ),
+                producer = |(can_be_provisioned, is_logged_in)| {
+                    if is_logged_in {
+                        children()
+                    } else if can_be_provisioned {
+                        use_navigate()(format!("/provision/?back={}", back()).as_str(), NavigateOptions::default());
+                        ().into_any()
+                    } else {
+                        use_navigate()(format!("/login/?back={}", back()).as_str(), NavigateOptions::default());
+                        ().into_any()
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/components/mod.rs b/src/components/mod.rs
index 2c3d79a..2a3a0b1 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -3,15 +3,22 @@ use std::sync::atomic::{AtomicU32, Ordering};
 // Generic
 pub mod async_fetch;
 pub mod banner;
+pub mod catch_errors;
 pub mod container;
 pub mod form;
 pub mod icon_p;
+pub mod login_wall;
+
+// placeholders
+pub mod checkbox_placeholder;
 pub mod input_placeholder;
 pub mod select_placeholder;
+pub mod textarea_placeholder;
 
 // Specific
 pub mod inventory;
 pub mod product_overview;
+pub mod product_parent_overview;
 pub mod recipies;
 pub mod site_header;
 pub mod unit_overview;
diff --git a/src/components/product_overview.rs b/src/components/product_overview.rs
index bf81624..233b8a7 100644
--- a/src/components/product_overview.rs
+++ b/src/components/product_overview.rs
@@ -11,9 +11,20 @@ pub fn ProductOverview() -> impl IntoView {
         <Container
             header="Products"
             buttons=vec![
-                (view! { <IconP icon=icondata_io::IoClipboard text="Show products" /> }, "products"),
-                (view! { <IconP icon=icondata_io::IoPricetags text="Create product" /> }, "create-product"),
-                (view! { <IconP icon=icondata_io::IoPricetags text="Associate barcode with product" /> }, "associate-barcode-product"),
+                (view! { <IconP icon=icondata_io::IoClipboard text="Show" /> }, "products"),
+                (
+                    view! { <IconP icon=icondata_io::IoPricetags text="Create product" /> },
+                    "create-product",
+                ),
+                (
+                    view! {
+                        <IconP
+                            icon=icondata_io::IoPricetags
+                            text="Associate barcode with product"
+                        />
+                    },
+                    "associate-barcode-product",
+                ),
             ]
         >
             {
@@ -22,7 +33,7 @@ pub fn ProductOverview() -> impl IntoView {
                     fetcher = products_registered_wrapped(),
                     producer = |products| {
                         view! {
-                            <p>{format!("You have {} products", products.len())}</p>
+                            <p>{format!("You have {} products.", products.len())}</p>
                         }
                     }
                 }
diff --git a/src/components/product_parent_overview.rs b/src/components/product_parent_overview.rs
new file mode 100644
index 0000000..4aa2a0f
--- /dev/null
+++ b/src/components/product_parent_overview.rs
@@ -0,0 +1,37 @@
+use leptos::{IntoView, component, view};
+
+use crate::{
+    api::product_parents_wrapped,
+    components::{async_fetch::AsyncFetch, container::Container, icon_p::IconP},
+};
+
+#[component]
+pub fn ProductParentOverview() -> impl IntoView {
+    view! {
+        <Container
+            header="Products Parents"
+            buttons=vec![
+                (
+                    view! { <IconP icon=icondata_io::IoClipboard text="Show products" /> },
+                    "products",
+                ),
+                (
+                    view! { <IconP icon=icondata_io::IoPricetags text="Create product parent" /> },
+                    "create-product-parent",
+                ),
+            ]
+        >
+            {
+                AsyncFetch! {
+                    @map_error_in_producer
+                    fetcher = product_parents_wrapped(),
+                    producer = |product_parents| {
+                        view! {
+                            <p>{format!("You have {} product parents.", product_parents.len())}</p>
+                        }
+                    }
+                }
+            }
+        </Container>
+    }
+}
diff --git a/src/components/recipies.rs b/src/components/recipies.rs
index f7903e4..755954e 100644
--- a/src/components/recipies.rs
+++ b/src/components/recipies.rs
@@ -1,6 +1,9 @@
-use leptos::{IntoView, component, prelude::ElementChild, view};
+use leptos::{IntoView, component, view};
 
-use crate::components::{container::Container, icon_p::IconP};
+use crate::{
+    api::recipes_wrapped,
+    components::{async_fetch::AsyncFetch, container::Container, icon_p::IconP},
+};
 
 #[component]
 pub fn Recipies() -> impl IntoView {
@@ -9,10 +12,21 @@ pub fn Recipies() -> impl IntoView {
             header="Recipies"
             buttons=vec![
                 (view! { <IconP icon=icondata_io::IoFastFood text="Recipies" /> }, "recipies"),
+                (view! { <IconP icon=icondata_io::IoPin text="Create recipe" /> }, "create-recipe"),
                 (view! { <IconP icon=icondata_io::IoCalendarSharp text="Mealplan" /> }, "mealplan"),
             ]
         >
-            <p>"You have 0 recipies."</p>
+            {
+                AsyncFetch! {
+                    @map_error_in_producer
+                    fetcher = recipes_wrapped(),
+                    producer = |recipes| {
+                        view! {
+                            <p>{format!("You have {} recipies.", recipes.len())}</p>
+                        }
+                    }
+                }
+            }
         </Container>
     }
 }
diff --git a/src/components/textarea_placeholder.rs b/src/components/textarea_placeholder.rs
new file mode 100644
index 0000000..a0bae6d
--- /dev/null
+++ b/src/components/textarea_placeholder.rs
@@ -0,0 +1,60 @@
+use leptos::{
+    IntoView, component,
+    html::Textarea,
+    prelude::{ClassAttribute, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute},
+    view,
+};
+
+use crate::components::get_id;
+
+#[component]
+pub fn TextareaPlaceholder(
+    label: &'static str,
+    node_ref: NodeRef<Textarea>,
+    #[prop(default = None)] initial_value: Option<String>,
+) -> impl IntoView {
+    let id = get_id();
+
+    view! {
+        <div class="relative h-80">
+            <textarea
+                id=id.to_string()
+                class="\
+                absolute \
+                bottom-0 \
+                bg-gray-200 \
+                border-2 \
+                border-b-2 \
+                border-b-trasparent \
+                border-gray-200 \
+                focus:border-indigo-600 \
+                focus:border-b-transparent \
+                focus:outline-none \
+                h-[300px] \
+                placeholder-transparent \
+                rounded-t-lg \
+                text-gray-900 \
+                w-full \
+                "
+                placeholder="sentinel value"
+                node_ref=node_ref
+            >
+                {initial_value}
+            </textarea>
+
+            <label
+                for=id.to_string()
+                class="\
+                absolute \
+                transition-all \
+                text-sm \
+                text-gray-700 \
+                top-0 \
+                left-0 \
+                "
+            >
+                {label}
+            </label>
+        </div>
+    }
+}
diff --git a/src/components/unit_overview.rs b/src/components/unit_overview.rs
index 25e5675..0ea3825 100644
--- a/src/components/unit_overview.rs
+++ b/src/components/unit_overview.rs
@@ -1,6 +1,10 @@
 use leptos::{IntoView, component, view};
+use rocie_client::models::{Unit, UnitProperty};
 
-use crate::components::{container::Container, icon_p::IconP};
+use crate::{
+    api::{unit_properties_wrapped, units_wrapped},
+    components::{async_fetch::AsyncFetch, container::Container, icon_p::IconP},
+};
 
 #[component]
 pub fn UnitOverview() -> impl IntoView {
@@ -8,14 +12,37 @@ pub fn UnitOverview() -> impl IntoView {
         <Container
             header="Units"
             buttons=vec![
-                (view! { <IconP icon=icondata_io::IoClipboard text="Show unit" /> }, "units"),
-                (view! { <IconP icon=icondata_io::IoClipboard text="Create unit" /> }, "create-unit"),
-                (view! { <IconP icon=icondata_io::IoPricetags text="Create unit property" /> }, "create-unit-property"),
+                (view! { <IconP icon=icondata_io::IoClipboard text="Show" /> }, "units"),
+                (
+                    view! { <IconP icon=icondata_io::IoClipboard text="Create unit" /> },
+                    "create-unit",
+                ),
+                (
+                    view! { <IconP icon=icondata_io::IoPricetags text="Create unit property" /> },
+                    "create-unit-property",
+                ),
             ]
         >
             {
-                "You have units"
+                AsyncFetch! {
+                    @map_error_in_producer
+                    fetcher = get_units_and_unit_properties(),
+                    producer = |(units, unit_properties)| {
+                        view! {
+                            <p>{move || format!(
+                                "You have {} units and {} unit properties.",
+                                units.len(),
+                                unit_properties.len()
+                            )}</p>
+                        }
+                    },
+                }
             }
         </Container>
     }
 }
+
+async fn get_units_and_unit_properties()
+-> Result<(Vec<Unit>, Vec<UnitProperty>), leptos::error::Error> {
+    Ok((units_wrapped().await?, unit_properties_wrapped().await?))
+}
diff --git a/src/lib.rs b/src/lib.rs
index 36210e7..a884201 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -31,7 +31,10 @@ use reactive_stores::Store;
 use rocie_client::apis::configuration::Configuration;
 
 use crate::pages::{
-    associate_barcode::AssociateBarcode, buy::Buy, create_product::CreateProduct, home::Home, inventory::Inventory, not_found::NotFound, recipies::Recipies
+    associate_barcode::AssociateBarcode, buy::Buy, create_product::CreateProduct,
+    create_product_parent::CreateProductParent, create_recipe::CreateRecipe, home::Home,
+    inventory::Inventory, login::Login, not_found::NotFound, product::Product, products::Products,
+    provision::Provision, recipe::Recipe, recipies::Recipies, units::Units,
 };
 
 #[derive(Debug, Clone, Store)]
@@ -40,6 +43,7 @@ pub struct ConfigState {
 }
 
 #[component]
+#[expect(clippy::too_many_lines)]
 pub fn App() -> impl IntoView {
     // Provides context that manages stylesheets, titles, meta tags, etc.
     provide_meta_context();
@@ -48,7 +52,7 @@ pub fn App() -> impl IntoView {
         let mut config = Configuration::new();
 
         config.user_agent = Some("rocie-mobile".to_owned());
-        "http://127.0.0.1:8080".clone_into(&mut config.base_path);
+        "/api/".clone_into(&mut config.base_path);
 
         config
     };
@@ -71,6 +75,18 @@ pub fn App() -> impl IntoView {
                         view! { <Home /> }
                     }
                 />
+                <Route
+                    path=path!("/login")
+                    view=move || {
+                        view! { <Login /> }
+                    }
+                />
+                <Route
+                    path=path!("/provision")
+                    view=move || {
+                        view! { <Provision /> }
+                    }
+                />
 
                 // Inventory
                 <Route
@@ -93,9 +109,27 @@ pub fn App() -> impl IntoView {
                         view! { <Recipies /> }
                     }
                 />
+                <Route
+                    path=path!("/recipe/:name")
+                    view=move || {
+                        view! { <Recipe /> }
+                    }
+                />
+                <Route
+                    path=path!("/create-recipe")
+                    view=move || {
+                        view! { <CreateRecipe /> }
+                    }
+                />
 
                 // Products
                 <Route
+                    path=path!("/products")
+                    view=move || {
+                        view! { <Products /> }
+                    }
+                />
+                <Route
                     path=path!("/create-product")
                     view=move || {
                         view! { <CreateProduct /> }
@@ -107,6 +141,28 @@ pub fn App() -> impl IntoView {
                         view! { <AssociateBarcode /> }
                     }
                 />
+                <Route
+                    path=path!("/product/:name")
+                    view=move || {
+                        view! { <Product /> }
+                    }
+                />
+
+                // Product Parents
+                <Route
+                    path=path!("/create-product-parent")
+                    view=move || {
+                        view! { <CreateProductParent /> }
+                    }
+                />
+
+                // Units
+                <Route
+                    path=path!("/units")
+                    view=move || {
+                        view! { <Units /> }
+                    }
+                />
             </Routes>
         </Router>
     }
diff --git a/src/pages/associate_barcode.rs b/src/pages/associate_barcode.rs
index 20714ff..0e1308d 100644
--- a/src/pages/associate_barcode.rs
+++ b/src/pages/associate_barcode.rs
@@ -1,9 +1,10 @@
 use leptos::{
     IntoView, component,
-    prelude::{Get, Show, WriteSignal, signal},
+    prelude::{ElementExt, Get, Show, WriteSignal, signal},
     task::spawn_local,
     view,
 };
+use leptos_router::{NavigateOptions, hooks::use_navigate};
 use rocie_client::models::{Barcode, BarcodeId, Product, Unit, UnitAmount, UnitId};
 use rocie_macros::Form;
 use uuid::Uuid;
@@ -14,55 +15,62 @@ use crate::{
         product_by_name_404_wrapped, product_by_name_external_wrapped,
         product_suggestion_by_name_wrapped, unit_by_id_wrapped, unit_property_by_id_wrapped,
     },
-    components::{async_fetch::AsyncResource, banner::Banner, site_header::SiteHeader},
+    components::{
+        async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors,
+        login_wall::LoginWall, site_header::SiteHeader,
+    },
 };
 
 #[component]
 pub fn AssociateBarcode() -> impl IntoView {
-    let product_name_signal;
-
     let (errors, errors_set) = signal(None);
 
     let (show_units, show_units_set) = signal(false);
 
     view! {
-        <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" />
-
-        <Show when=move || errors.get().is_some()>
-            <Banner text=move || errors.get().expect("Was some") />
-        </Show>
-
-        {
-            Form! {
-                on_submit = |barcode_id, product_name, amount, unit_id| {
-                    let config = get_config!();
-
-                    spawn_local(async move {
-                        let output = async {
-                            let product = product_by_name_external_wrapped(&config, &product_name).await?;
-
-                            associate_barcode_external_wrapped(&config, product.id, Barcode {
-                                amount:UnitAmount {
-                                    unit: UnitId { value: unit_id },
-                                    value: u32::from(amount),
-                                },
-                                id: BarcodeId { value: barcode_id },
-                            }).await?;
-
-                            Ok::<_, leptos::error::Error>(())
-                        };
-
-                        match output.await {
-                            Ok(()) => (),
-                            Err(err) => {
-                                errors_set.set(
-                                    Some(
-                                        format!("Could not associate barcode: {err}")
-                                    )
-                                );
-                            },
-                        }
-                    });
+        <CatchErrors>
+            <LoginWall back=move || "/associate-barcode-product".to_owned()>
+                <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" />
+
+                <Show when=move || errors.get().is_some()>
+                    <Banner text=move || errors.get().expect("Was some") />
+                </Show>
+
+                {
+                    let product_name_signal;
+                    Form! {
+                        on_submit = |barcode_id, product_name, amount, unit_id| {
+                            let config = get_config!();
+                            let navigate = use_navigate();
+
+                            spawn_local(async move {
+                                let output = async {
+                                    let product = product_by_name_external_wrapped(&config, product_name.trim()).await?;
+
+                                    associate_barcode_external_wrapped(&config, product.id, Barcode {
+                                        amount:UnitAmount {
+                                            unit: UnitId { value: unit_id },
+                                            value: u32::from(amount),
+                                        },
+                                        id: BarcodeId { value: barcode_id },
+                                    }).await?;
+
+                                    Ok::<_, leptos::error::Error>(())
+                                };
+
+                                match output.await {
+                                    Ok(()) => {
+                                        navigate("/associate-barcode-product", NavigateOptions::default());
+                                    },
+                                    Err(err) => {
+                                        errors_set.set(
+                                            Some(
+                                                format!("Could not associate barcode: {err}")
+                                            )
+                                        );
+                                    },
+                                }
+                        });
                 };
 
                 <Input
@@ -117,8 +125,10 @@ pub fn AssociateBarcode() -> impl IntoView {
                     html_type="number",
                     label="Amount"
                 />
-            }
-        }
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
     }
 }
 
diff --git a/src/pages/buy.rs b/src/pages/buy.rs
index f3335f6..e4cd599 100644
--- a/src/pages/buy.rs
+++ b/src/pages/buy.rs
@@ -4,12 +4,16 @@ use leptos::{
     task::spawn_local,
     view,
 };
+use leptos_router::{NavigateOptions, hooks::use_navigate};
 use log::info;
 use rocie_client::models::BarcodeId;
 
 use crate::{
     api::{buy_barcode_external_wrapped, get_config},
-    components::{banner::Banner, form::Form, site_header::SiteHeader},
+    components::{
+        banner::Banner, catch_errors::CatchErrors, form::Form, login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
 };
 
 #[component]
@@ -17,29 +21,44 @@ 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" />
+        <CatchErrors>
+            <LoginWall back=move || "/buy".to_owned()>
+                <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>
+                <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, times| {
-                    let config = get_config!();
+                {
+                    Form! {
+                    on_submit = |barcode_number, times| {
+                        let config = get_config!();
+                        let navigate = use_navigate();
 
-                    spawn_local(async move {
-                        if let Err(err) = buy_barcode_external_wrapped(&config, BarcodeId { value: barcode_number }, u32::from(times)).await {
-                            let error = format!("Error in form on-submit for barcode `{barcode_number}`: {err}");
+                        spawn_local(async move {
+                            match buy_barcode_external_wrapped(
+                                &config,
+                                BarcodeId { value: barcode_number },
+                                u32::from(times)
+                            ).await {
+                                Ok(()) => {
+                                    navigate("/buy", NavigateOptions::default());
+                                    on_submit_errored_set.set(None);
+                                },
+                                Err(err) => {
+                                    let error =
+                                        format!(
+                                            "Error in form \
+                                            on-submit for barcode \
+                                            `{barcode_number}`: {err}"
+                                        );
+                                    on_submit_errored_set.set(Some(error));
+                                },
+                            }
 
-                            on_submit_errored_set.set(Some(error));
-                        } else {
-                            on_submit_errored_set.set(None);
-                        }
 
-
-                        info!("Bought barcode {barcode_number} {times} times");
-                    });
+                            info!("Bought barcode {barcode_number} {times} times");
+                        });
                 };
 
                 <Input
@@ -55,7 +74,9 @@ pub fn Buy() -> impl IntoView {
                     html_type="number",
                     label="Times"
                 />
-            }
-        }
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
     }
 }
diff --git a/src/pages/create_product.rs b/src/pages/create_product.rs
index fcd3b0b..fdf8f28 100644
--- a/src/pages/create_product.rs
+++ b/src/pages/create_product.rs
@@ -1,4 +1,4 @@
-use std::{convert::Infallible, str::FromStr};
+use std::{convert::Infallible, iter, str::FromStr};
 
 use leptos::{
     IntoView, component,
@@ -6,16 +6,23 @@ use leptos::{
     task::spawn_local,
     view,
 };
-use rocie_client::models::{ProductStub, UnitPropertyId};
+use leptos_router::{NavigateOptions, hooks::use_navigate};
+use rocie_client::models::{ProductParentId, ProductStub, UnitPropertyId};
 use rocie_macros::Form;
 use uuid::Uuid;
 
 use crate::{
-    api::{get_config, register_product_external_wrapped, unit_properties_wrapped},
-    components::{async_fetch::AsyncResource, banner::Banner, site_header::SiteHeader},
+    api::{
+        get_config, product_parents_wrapped, register_product_external_wrapped,
+        unit_properties_wrapped,
+    },
+    components::{
+        async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors,
+        login_wall::LoginWall, site_header::SiteHeader,
+    },
 };
 
-struct OptionalString(Option<String>);
+pub(crate) struct OptionalString(pub(crate) Option<String>);
 
 impl FromStr for OptionalString {
     type Err = Infallible;
@@ -29,35 +36,54 @@ impl FromStr for OptionalString {
     }
 }
 
+struct OptionalParentId(Option<ProductParentId>);
+
+impl FromStr for OptionalParentId {
+    type Err = uuid::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s.is_empty() {
+            Ok(Self(None))
+        } else {
+            Ok(Self(Some(ProductParentId { value: s.parse()? })))
+        }
+    }
+}
+
 #[component]
 pub fn CreateProduct() -> impl IntoView {
     let (error_message, error_message_set) = signal(None);
 
     view! {
-        <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Create Product" />
-
-        <Show when=move || error_message.get().is_some()>
-            <Banner text=move || error_message.get().expect("Is some") />
-        </Show>
-
-        {
-            Form! {
-                on_submit = |product_name, product_description, unit_property_id| {
-                    let config = get_config!();
-
-                    spawn_local(async move {
-                        match register_product_external_wrapped(&config, ProductStub {
-                                description: Some(product_description.0),
-                                name: product_name,
-                                parent: None, // TODO: Add this <2025-10-25>
-                                unit_property: UnitPropertyId { value: unit_property_id },
+        <CatchErrors>
+            <LoginWall back=move || "/create-product".to_owned()>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Create Product" />
+
+                <Show when=move || error_message.get().is_some()>
+                    <Banner text=move || error_message.get().expect("Is some") />
+                </Show>
+
+                {
+                    Form! {
+                        on_submit = |product_name, product_description, unit_property_id, parent| {
+                        let config = get_config!();
+                        let navigate = use_navigate();
+
+                        spawn_local(async move {
+                            match register_product_external_wrapped(&config, ProductStub {
+                                    description: product_description.0.map(|d| d.trim().to_owned()),
+                                    name: product_name.trim().to_owned(),
+                                    parent: parent.0,
+                                    unit_property: UnitPropertyId { value: unit_property_id },
+                                }
+                            ).await {
+                                Ok(_id) => {
+                                    navigate("/create-product", NavigateOptions::default());
+                                }
+                                Err(err) => error_message_set.set(Some(format!("Failed to create product: {err}"))),
                             }
-                        ).await {
-                            Ok(_id) => {}
-                            Err(err) => error_message_set.set(Some(format!("Failed to create product: {err}"))),
-                        }
-                    });
-                };
+                        });
+                    };
 
                 <Input
                     name=product_name,
@@ -74,6 +100,27 @@ pub fn CreateProduct() -> impl IntoView {
                 />
 
                 <Select
+                    name=parent,
+                    rust_type=OptionalParentId,
+                    label="Parent",
+                    options=AsyncResource! {
+                        () -> Result<Vec<(String, String)>, leptos::error::Error> {
+                            let parents = product_parents_wrapped().await?;
+
+                            Ok(
+                                iter::once(("No parent".to_owned(), String::new()))
+                                    .chain(
+                                        parents
+                                            .into_iter()
+                                            .map(|prop| (prop.name, prop.id.to_string()))
+                                    )
+                                    .collect()
+                            )
+                        }
+                    },
+                />
+
+                <Select
                     name=unit_property_id,
                     rust_type=Uuid,
                     label="Unit property",
@@ -90,7 +137,9 @@ pub fn CreateProduct() -> impl IntoView {
                         }
                     },
                 />
-            }
-        }
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
     }
 }
diff --git a/src/pages/create_product_parent.rs b/src/pages/create_product_parent.rs
new file mode 100644
index 0000000..152347a
--- /dev/null
+++ b/src/pages/create_product_parent.rs
@@ -0,0 +1,126 @@
+use std::{convert::Infallible, iter, str::FromStr};
+
+use leptos::{
+    IntoView, component,
+    prelude::{Get, Show, signal},
+    task::spawn_local,
+    view,
+};
+use leptos_router::{NavigateOptions, hooks::use_navigate};
+use rocie_client::models::{ProductParentId, ProductParentStub};
+use rocie_macros::Form;
+
+use crate::{
+    api::{get_config, product_parents_wrapped, register_product_parent_external_wrapped},
+    components::{
+        async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors,
+        login_wall::LoginWall, site_header::SiteHeader,
+    },
+};
+
+struct OptionalString(Option<String>);
+
+impl FromStr for OptionalString {
+    type Err = Infallible;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s.is_empty() {
+            Ok(Self(None))
+        } else {
+            Ok(Self(Some(s.to_owned())))
+        }
+    }
+}
+
+struct OptionalParentId(Option<ProductParentId>);
+
+impl FromStr for OptionalParentId {
+    type Err = uuid::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s.is_empty() {
+            Ok(Self(None))
+        } else {
+            Ok(Self(Some(ProductParentId { value: s.parse()? })))
+        }
+    }
+}
+
+#[component]
+pub fn CreateProductParent() -> impl IntoView {
+    let (error_message, error_message_set) = signal(None);
+
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || "/create-product-parent".to_owned()>
+                <SiteHeader
+                    logo=icondata_io::IoArrowBack
+                    back_location="/"
+                    name="Create Product Parent"
+                />
+
+                <Show when=move || error_message.get().is_some()>
+                    <Banner text=move || error_message.get().expect("Is some") />
+                </Show>
+
+                {
+                    Form! {
+                        on_submit = |name, description, parent| {
+                    let config = get_config!();
+                    let navigate = use_navigate();
+
+                    spawn_local(async move {
+                        match register_product_parent_external_wrapped(&config, ProductParentStub {
+                                description: description.0.map(|d| d.trim().to_owned()),
+                                name: name.trim().to_owned(),
+                                parent: parent.0,
+                            }
+                        ).await {
+                            Ok(_id) => {
+                                navigate("/create-product-parent", NavigateOptions::default());
+                            }
+                            Err(err) => error_message_set.set(Some(format!("Failed to create product: {err}"))),
+                        }
+                    });
+                };
+
+                <Input
+                    name=name,
+                    rust_type=String,
+                    html_type="text",
+                    label="Product Name",
+                />
+
+                <Input
+                    name=description,
+                    rust_type=OptionalString,
+                    html_type="text",
+                    label="Product Description"
+                />
+
+                <Select
+                    name=parent,
+                    rust_type=OptionalParentId,
+                    label="Parent",
+                    options=AsyncResource! {
+                        () -> Result<Vec<(String, String)>, leptos::error::Error> {
+                            let parents = product_parents_wrapped().await?;
+
+                            Ok(
+                                iter::once(("No parent".to_owned(), String::new()))
+                                    .chain(
+                                        parents
+                                            .into_iter()
+                                            .map(|prop| (prop.name, prop.id.to_string()))
+                                    )
+                                    .collect()
+                            )
+                        }
+                    },
+                />
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
+    }
+}
diff --git a/src/pages/create_recipe.rs b/src/pages/create_recipe.rs
new file mode 100644
index 0000000..20ec4ed
--- /dev/null
+++ b/src/pages/create_recipe.rs
@@ -0,0 +1,108 @@
+use std::{iter, str::FromStr};
+
+use leptos::{
+    IntoView, component,
+    prelude::{Get, Show, signal},
+    task::spawn_local,
+    view,
+};
+use leptos_router::{NavigateOptions, hooks::use_navigate};
+use log::info;
+use rocie_client::models::{RecipeParentId, RecipeStub};
+use rocie_macros::Form;
+
+use crate::{
+    api::{add_recipe_external_wrapped, get_config, recipe_parents_wrapped},
+    components::{
+        async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors,
+        login_wall::LoginWall, site_header::SiteHeader,
+    },
+    pages::create_product::OptionalString,
+};
+
+struct OptionalParentId(Option<RecipeParentId>);
+
+impl FromStr for OptionalParentId {
+    type Err = uuid::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s.is_empty() {
+            Ok(Self(None))
+        } else {
+            Ok(Self(Some(RecipeParentId { value: s.parse()? })))
+        }
+    }
+}
+
+#[component]
+pub fn CreateRecipe() -> impl IntoView {
+    let (error_message, error_message_set) = signal(None);
+
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || "/create-recipe".to_owned()>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Create Recipe" />
+
+                <Show when=move || error_message.get().is_some()>
+                    <Banner text=move || error_message.get().expect("Is some") />
+                </Show>
+
+                {
+                    Form! {
+                        on_submit = |content, name, parent| {
+                        let config = get_config!();
+                        let navigate = use_navigate();
+
+                        spawn_local(async move {
+                            match add_recipe_external_wrapped(&config, RecipeStub {
+                                content: content.trim().to_owned(),
+                                name: name.trim().to_owned(),
+                                parent: parent.0,
+                            }).await {
+                                Ok(_id) => {
+                                    info!("Navigating");
+                                    navigate("/create-recipe", NavigateOptions::default());
+                                }
+                                Err(err) => error_message_set.set(Some(format!("Failed to create recipe: {err}"))),
+                            }
+                        });
+                    };
+
+                <Input
+                    name=name,
+                    rust_type=String,
+                    html_type="text",
+                    label="Recipe Name",
+                />
+
+                <Select
+                    name=parent,
+                    rust_type=OptionalParentId,
+                    label="Parent",
+                    options=AsyncResource! {
+                        () -> Result<Vec<(String, String)>, leptos::error::Error> {
+                            let parents = recipe_parents_wrapped().await?;
+
+                            Ok(
+                                iter::once(("No parent".to_owned(), String::new()))
+                                    .chain(
+                                        parents
+                                            .into_iter()
+                                            .map(|prop| (prop.name, prop.id.to_string()))
+                                    )
+                                    .collect()
+                            )
+                        }
+                    },
+                />
+
+                <Textarea
+                    name=content,
+                    label="Recipe",
+                />
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
+    }
+}
diff --git a/src/pages/home.rs b/src/pages/home.rs
index b9dba64..e3767fd 100644
--- a/src/pages/home.rs
+++ b/src/pages/home.rs
@@ -1,7 +1,6 @@
 use leptos::{
     IntoView, component,
-    error::ErrorBoundary,
-    prelude::{ClassAttribute, CollectView, ElementChild, Get, GetUntracked},
+    prelude::{ClassAttribute, ElementChild, GetUntracked},
     view,
 };
 use leptos_router::{
@@ -10,8 +9,9 @@ use leptos_router::{
 };
 
 use crate::components::{
-    inventory::Inventory, product_overview::ProductOverview, recipies::Recipies,
-    site_header::SiteHeader, unit_overview::UnitOverview,
+    catch_errors::CatchErrors, inventory::Inventory, login_wall::LoginWall,
+    product_overview::ProductOverview, product_parent_overview::ProductParentOverview,
+    recipies::Recipies, site_header::SiteHeader, unit_overview::UnitOverview,
 };
 
 #[component]
@@ -24,31 +24,19 @@ pub fn Home() -> impl IntoView {
     }
 
     view! {
-        <ErrorBoundary fallback=|errors| {
-            view! {
-                <h1>"Uh oh! Something went wrong!"</h1>
-
-                <p>"Errors: "</p>
-                // Render a list of errors as strings - good for development purposes
-                <ul>
-                    {move || {
-                        errors
-                            .get()
-                            .into_iter()
-                            .map(|(_, e)| view! { <li>{e.to_string()}</li> })
-                            .collect_view()
-                    }}
-                </ul>
-            }
-        }>
-
-            <div class="flex flex-col content-start">
+        <CatchErrors>
+            <LoginWall back=move || "/".to_owned()>
                 <SiteHeader logo=icondata_io::IoRoseSharp back_location="/" name="Rocie" />
-                <Inventory />
-                <Recipies />
-                <ProductOverview />
-                <UnitOverview />
-            </div>
-        </ErrorBoundary>
+
+                <div class="flex flex-col content-start">
+                    <Inventory />
+                    <Recipies />
+                    <hr class="w-8 h-0.5 rounded-lg mt-4 self-center bg-gray-500" />
+                    <ProductOverview />
+                    <UnitOverview />
+                    <ProductParentOverview />
+                </div>
+            </LoginWall>
+        </CatchErrors>
     }
 }
diff --git a/src/pages/inventory.rs b/src/pages/inventory.rs
index b2ce4a1..0ad5613 100644
--- a/src/pages/inventory.rs
+++ b/src/pages/inventory.rs
@@ -7,26 +7,33 @@ use rocie_client::models::{Product, ProductAmount, ProductId, Unit};
 
 use crate::{
     api::{
-        amount_by_id_wrapped, product_by_id_wrapped, products_in_storage_wrapped,
+        amount_by_id_404_wrapped, product_by_id_wrapped, products_in_storage_wrapped,
         unit_by_id_wrapped,
     },
-    components::{async_fetch::AsyncFetch, site_header::SiteHeader},
+    components::{
+        async_fetch::AsyncFetch, catch_errors::CatchErrors, login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
 };
 
 #[component]
 pub fn Inventory() -> impl IntoView {
     view! {
-        <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Inventory" />
+        <CatchErrors>
+            <LoginWall back=move || "/inventory".to_owned()>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Inventory" />
 
-        <ul class="flex flex-col p-2 m-2">
-            {
-                AsyncFetch! {
-                    @map_error_in_producer
-                    fetcher = products_in_storage_wrapped(),
-                    producer = render_products,
-                }
-            }
-        </ul>
+                <ul class="flex flex-col p-2 m-2">
+                    {
+                        AsyncFetch! {
+                            @map_error_in_producer
+                        fetcher = products_in_storage_wrapped(),
+                        producer = render_products,
+                        }
+                    }
+                </ul>
+            </LoginWall>
+        </CatchErrors>
     }
 }
 
@@ -45,23 +52,30 @@ fn render_products(products: Vec<Product>) -> impl IntoView {
 
 async fn get_full_product_by_id(
     id: ProductId,
-) -> Result<(Product, ProductAmount, Unit), leptos::error::Error> {
+) -> Result<Option<(Product, ProductAmount, Unit)>, leptos::error::Error> {
     let product = product_by_id_wrapped(id).await?;
-    let amount = amount_by_id_wrapped(id).await?;
-    let unit = unit_by_id_wrapped(amount.amount.unit).await?;
+    let amount = amount_by_id_404_wrapped(id).await?;
 
-    Ok((product, amount, unit))
-}
+    if let Some(amount) = amount {
+        let unit = unit_by_id_wrapped(amount.amount.unit).await?;
 
-fn format_full_product((product, amount, unit): (Product, ProductAmount, Unit)) -> impl IntoView {
-    view! {
-        <ul class="my-3">
-            <li class="m-2">{product.name}</li>
-            <li class="m-2">
-                <span class="bg-gray-200 p-1 px-2 rounded-lg">
-                    {format!("{} {}", amount.amount.value, unit.short_name)}
-                </span>
-            </li>
-        </ul>
+        Ok(Some((product, amount, unit)))
+    } else {
+        Ok(None)
     }
 }
+
+fn format_full_product(maybe_product: Option<(Product, ProductAmount, Unit)>) -> impl IntoView {
+    maybe_product.map(|(product, amount, unit)| {
+        view! {
+            <ul class="my-3">
+                <li class="m-2">{product.name}</li>
+                <li class="m-2">
+                    <span class="bg-gray-200 p-1 px-2 rounded-lg">
+                        {format!("{} {}", amount.amount.value, unit.short_name)}
+                    </span>
+                </li>
+            </ul>
+        }
+    })
+}
diff --git a/src/pages/login.rs b/src/pages/login.rs
new file mode 100644
index 0000000..af3f660
--- /dev/null
+++ b/src/pages/login.rs
@@ -0,0 +1,81 @@
+use leptos::{
+    IntoView, component,
+    prelude::{Get, Show, signal},
+    task::spawn_local,
+    view,
+};
+use leptos_router::{
+    NavigateOptions,
+    hooks::{use_navigate, use_query_map},
+};
+use rocie_client::models::LoginInfo;
+use rocie_macros::Form;
+
+use crate::{
+    api::{get_config, login_external_wrapped},
+    components::{banner::Banner, catch_errors::CatchErrors, site_header::SiteHeader},
+};
+
+#[component]
+pub fn Login() -> impl IntoView {
+    let back = || {
+        let back = use_query_map()
+            .get()
+            .get("back")
+            .expect("Should always have a back, because the router would otherwise not match");
+
+        if back.starts_with('/') {
+            back
+        } else {
+            // Prevent a redirect like `/login?back=https://gnu.org` to work
+            "/".to_owned()
+        }
+    };
+
+    let (error_message, error_message_set) = signal(None);
+
+    view! {
+        <CatchErrors>
+            <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Login" />
+
+            <Show when=move || error_message.get().is_some()>
+                <Banner text=move || error_message.get().expect("Is some") />
+            </Show>
+
+            {
+                Form! {
+                    on_submit = |user_name, password| {
+                        let config = get_config!();
+                        let navigate = use_navigate();
+                        let back = back();
+
+                        spawn_local(async move {
+                            match login_external_wrapped(
+                                &config,
+                                LoginInfo { user_name, password }
+                            ).await {
+                                Ok(()) => {
+                                    navigate(back.as_str(), NavigateOptions::default());
+                                }
+                                Err(err) => error_message_set.set(Some(format!("Failed to login: {err}"))),
+                            }
+                        });
+                    };
+
+                    <Input
+                        name=user_name,
+                        rust_type=String,
+                        html_type="text",
+                        label="Username"
+                    />
+                    <Input
+                        name=password,
+                        rust_type=String,
+                        html_type="password",
+                        label="Password"
+                    />
+                }
+            }
+        </CatchErrors>
+    }
+}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index b8a68c7..8a38db2 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,7 +1,86 @@
+pub mod associate_barcode;
 pub mod buy;
+pub mod create_product;
+pub mod create_product_parent;
+pub mod create_recipe;
 pub mod home;
 pub mod inventory;
+pub mod login;
 pub mod not_found;
+pub mod product;
+pub mod products;
+pub mod provision;
+pub mod recipe;
 pub mod recipies;
-pub mod create_product;
-pub mod associate_barcode;
+pub mod units;
+
+macro_rules! mk_render_parents {
+    (
+        self = $self:ident,
+        parent_type = $parent_type:ty,
+        item_type = $item_type:ty,
+        value_renderer = $value_renderer:ident,
+        under_parent_fetcher = $under_parent_fetcher:ident,
+        indirect_fetcher = $indirect_fetcher:ident,
+        direct_fetcher = $direct_fetcher:ident $(,)?
+    ) => {
+        fn $self(
+            parents: Option<Vec<$parent_type>>,
+            toplevel_items: Option<Vec<$item_type>>,
+        ) -> impl IntoView {
+            use leptos::prelude::IntoAny;
+
+            view! {
+                {
+                    parents.map(|parents| {
+                        parents
+                            .into_iter()
+                            .map(|parent| {
+                                view! {
+                                    <li>
+                                        <details>
+                                            <summary>{parent.name} {" ("} {
+                                                    AsyncFetch! {
+                                                        @map_error_in_producer
+                                                        fetcher = $indirect_fetcher(parent.id),
+                                                        producer = |products| {products.len()}
+                                                    }
+                                            } {")"}</summary>
+
+                                            <ul class="flex flex-col p-2">
+                                                {
+                                                    AsyncFetch! {
+                                                        @map_error_in_producer
+                                                        fetcher = $under_parent_fetcher(parent.id),
+                                                        producer = |val| $self(val, None)
+                                                    }
+                                                }
+
+                                                {
+                                                    AsyncFetch! {
+                                                        @map_error_in_producer
+                                                        fetcher = $direct_fetcher(parent.id),
+                                                        producer = $value_renderer
+                                                    }
+                                                }
+                                            </ul>
+                                        </details>
+                                    </li>
+                                }
+                                .into_any()
+                            })
+                            .collect::<Vec<_>>()
+                    })
+                }
+                {
+                    if let Some(toplevel_items) = toplevel_items {
+                        $value_renderer(toplevel_items).into_any()
+                    } else {
+                        ().into_any()
+                    }
+                }
+            }
+        }
+    };
+}
+use mk_render_parents;
diff --git a/src/pages/not_found.rs b/src/pages/not_found.rs
index 7b5c127..2adb598 100644
--- a/src/pages/not_found.rs
+++ b/src/pages/not_found.rs
@@ -1,6 +1,11 @@
 use leptos::{IntoView, component, prelude::ElementChild, view};
 
+use crate::components::site_header::SiteHeader;
+
 #[component]
 pub fn NotFound() -> impl IntoView {
-    view! { <h1>"Uh oh!" <br /> "We couldn't find that page!"</h1> }
+    view! {
+        <SiteHeader logo=icondata_io::IoRoseSharp back_location="/" name="Not Found" />
+        <h1>"Uh oh!" <br /> "We couldn't find that page!"</h1>
+    }
 }
diff --git a/src/pages/product.rs b/src/pages/product.rs
new file mode 100644
index 0000000..0e4ac04
--- /dev/null
+++ b/src/pages/product.rs
@@ -0,0 +1,52 @@
+use leptos::{
+    IntoView, component,
+    prelude::{ElementChild, Get, IntoAny},
+    view,
+};
+use leptos_router::hooks::use_params_map;
+use rocie_client::models::product;
+
+use crate::{
+    api::product_by_name_wrapped,
+    components::{
+        async_fetch::{AsyncFetch, AsyncResource},
+        catch_errors::CatchErrors,
+        login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
+};
+
+#[component]
+pub fn Product() -> impl IntoView {
+    let name = || {
+        use_params_map()
+            .get()
+            .get("name")
+            .expect("Should always have a name, because the router would otherwise not match")
+    };
+
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || format!("/product/{}", name())>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" />
+                {
+                    AsyncFetch! {
+                        @map_error_in_producer
+                        from_resource=AsyncResource! {
+                            (name: String = name().clone())
+                            -> Result<product::Product, leptos::error::Error> {
+                                product_by_name_wrapped(&name).await
+
+                            }
+                        },
+                        producer=render_product
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
+    }
+}
+
+fn render_product(product: product::Product) -> impl IntoView {
+    view! { <h1>{product.name}</h1> }
+}
diff --git a/src/pages/products.rs b/src/pages/products.rs
new file mode 100644
index 0000000..5c5b885
--- /dev/null
+++ b/src/pages/products.rs
@@ -0,0 +1,68 @@
+use leptos::{
+    IntoView, component,
+    prelude::{ClassAttribute, ElementChild},
+    view,
+};
+use rocie_client::models::{Product, ProductParent};
+
+use crate::{
+    api::{
+        product_parents_toplevel_wrapped, product_parents_under_404_wrapped,
+        products_by_product_parent_id_direct_wrapped,
+        products_by_product_parent_id_indirect_wrapped, products_without_product_parent_wrapped,
+    },
+    components::{
+        async_fetch::{AsyncFetch, AsyncResource},
+        catch_errors::CatchErrors,
+        login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
+    pages::mk_render_parents,
+};
+
+#[component]
+pub fn Products() -> impl IntoView {
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || "/products".to_owned()>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Products" />
+
+                <ul class="flex flex-col p-2 m-2">
+                    {
+                        AsyncFetch! {
+                            @map_error_in_producer
+                        from_resource = AsyncResource!(
+                            () -> Result<(Vec<ProductParent>, Vec<Product>), leptos::error::Error> {
+                                Ok((
+                                    product_parents_toplevel_wrapped().await?,
+                                    products_without_product_parent_wrapped().await?
+                                ))
+                            }
+                        ),
+                        producer = |(parents, toplevel_products)| render_product_parents(Some(parents), Some(toplevel_products)),
+                        }
+                    }
+                </ul>
+            </LoginWall>
+        </CatchErrors>
+    }
+}
+
+mk_render_parents!(
+    self = render_product_parents,
+    parent_type = ProductParent,
+    item_type = Product,
+    value_renderer = render_products,
+    under_parent_fetcher = product_parents_under_404_wrapped,
+    indirect_fetcher = products_by_product_parent_id_indirect_wrapped,
+    direct_fetcher = products_by_product_parent_id_direct_wrapped,
+);
+
+fn render_products(products: Vec<Product>) -> impl IntoView {
+    products
+        .into_iter()
+        .map(|product| {
+            view! { <li>{product.name}</li> }
+        })
+        .collect::<Vec<_>>()
+}
diff --git a/src/pages/provision.rs b/src/pages/provision.rs
new file mode 100644
index 0000000..340a076
--- /dev/null
+++ b/src/pages/provision.rs
@@ -0,0 +1,93 @@
+use leptos::{
+    IntoView, component,
+    prelude::{Get, Show, signal},
+    task::spawn_local,
+    view,
+};
+use leptos_router::{
+    NavigateOptions,
+    hooks::{use_navigate, use_query_map},
+};
+use rocie_client::models::{ProvisionInfo, UserStub};
+use rocie_macros::Form;
+
+use crate::{
+    api::{get_config, provision_external_wrapped},
+    components::{banner::Banner, catch_errors::CatchErrors, site_header::SiteHeader},
+};
+
+#[component]
+pub fn Provision() -> impl IntoView {
+    let back = || {
+        let back = use_query_map()
+            .get()
+            .get("back")
+            .expect("Should always have a back, because the router would otherwise not match");
+
+        if back.starts_with('/') {
+            back
+        } else {
+            // Prevent a redirect like `/provision?back=https://gnu.org` to work
+            "/".to_owned()
+        }
+    };
+
+    let (error_message, error_message_set) = signal(None);
+
+    view! {
+        <CatchErrors>
+            <SiteHeader
+                logo=icondata_io::IoArrowBack
+                back_location="/"
+                name="Provision this instance"
+            />
+
+            <Show when=move || error_message.get().is_some()>
+                <Banner text=move || error_message.get().expect("Is some") />
+            </Show>
+
+            {
+                Form! {
+                    on_submit = |user_name, password, should_use_defaults| {
+                        let config = get_config!();
+                        let navigate = use_navigate();
+                        let back = back();
+
+                        spawn_local(async move {
+                            match provision_external_wrapped(
+                                &config,
+                                ProvisionInfo {
+                                    // TODO: Make it possible to give this user a description <2025-12-30>
+                                    user: UserStub { description: None, name: user_name, password },
+                                    use_defaults: should_use_defaults
+                                }
+                            ).await {
+                                Ok(_) => {
+                                    navigate(back.as_str(), NavigateOptions::default());
+                                }
+                                Err(err) => error_message_set.set(Some(format!("Failed to provision: {err}"))),
+                            }
+                        });
+                    };
+
+                    <Input
+                        name=user_name,
+                        rust_type=String,
+                        html_type="text",
+                        label="Username"
+                    />
+                    <Input
+                        name=password,
+                        rust_type=String,
+                        html_type="password",
+                        label="Password"
+                    />
+                    <Checkbox
+                        name=should_use_defaults,
+                        label="Use defaults"
+                    />
+                }
+            }
+        </CatchErrors>
+    }
+}
diff --git a/src/pages/recipe.rs b/src/pages/recipe.rs
new file mode 100644
index 0000000..4e56e1d
--- /dev/null
+++ b/src/pages/recipe.rs
@@ -0,0 +1,344 @@
+#![expect(
+    clippy::needless_pass_by_value,
+    reason = "It's soo much easier to just pass these values by value"
+)]
+
+use std::sync::Arc;
+
+use leptos::{
+    IntoView, component,
+    prelude::{ClassAttribute, CollectView, ElementChild, Get, GlobalAttributes, IntoAny},
+    view,
+};
+use leptos_router::hooks::use_params_map;
+use rocie_client::models::{self, Content, Ingredient, Item, Section, UnitAmount};
+
+use crate::{
+    api::{
+        product_by_id_wrapped, recipe_by_id_wrapped, recipe_by_name_wrapped, unit_by_id_wrapped,
+    },
+    components::{
+        async_fetch::{AsyncFetch, AsyncResource},
+        catch_errors::CatchErrors,
+        login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
+};
+
+#[component]
+pub fn Recipe() -> impl IntoView {
+    let name = || {
+        use_params_map()
+            .get()
+            .get("name")
+            .expect("Should always have a name, because the router would otherwise not match")
+    };
+
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || format!("/recipe/{}", name())>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" />
+                {
+                    AsyncFetch! {
+                        @map_error_in_producer
+                        from_resource=AsyncResource! {
+                            (name: String = name().clone())
+                            -> Result<models::recipe::Recipe, leptos::error::Error> {
+                                recipe_by_name_wrapped(&name).await
+
+                            }
+                        },
+                        producer=render_recipe
+                    }
+                }
+            </LoginWall>
+        </CatchErrors>
+    }
+}
+
+fn render_recipe(recipe: models::recipe::Recipe) -> impl IntoView {
+    let recipe_content = Arc::new(recipe.content.clone());
+
+    view! {
+        <div class="flex flex-col contents-start">
+            <h1>{recipe.name}</h1>
+
+            <div>
+                <h3 class="text-lg font-bold">"Ingredients"</h3>
+                <ul>
+                    {recipe
+                        .content
+                        .ingredients
+                        .into_iter()
+                        .map(|ingredient| {
+                            view! { <li>{render_ingredient_ingredient_list(ingredient)}</li> }
+                        })
+                        .collect_view()}
+                </ul>
+            </div>
+
+            <div>
+                <h3>"Productions steps"</h3>
+                {
+                    let local_recipe_content = Arc::clone(&recipe_content);
+                    recipe
+                        .content
+                        .sections
+                        .into_iter()
+                        .map(|section| {
+                            render_section(Arc::clone(&local_recipe_content), section)
+                        })
+                        .collect_view()
+                }
+            </div>
+
+        </div>
+    }
+}
+
+fn render_section(recipe: Arc<models::CooklangRecipe>, section: Section) -> impl IntoView {
+    view! {
+        <div>
+            <ol class="list-inside list-decimal">
+                {section
+                    .content
+                    .into_iter()
+                    .map(|content| {
+                        view! { <li>{render_content(Arc::clone(&recipe), content)}</li> }
+                    })
+                    .collect_view()}
+            </ol>
+        </div>
+    }
+}
+
+fn render_content(recipe: Arc<models::CooklangRecipe>, content: Content) -> impl IntoView {
+    match content {
+        Content::ContentOneOf(content_one_of) => {
+            // Step
+            let step = content_one_of.step;
+
+            step.items
+                .into_iter()
+                .map(|item| render_item(Arc::clone(&recipe), item))
+                .collect_view()
+                .into_any()
+        }
+        Content::ContentOneOf1(content_one_of1) => {
+            // Text
+            view! { {content_one_of1.text} }.into_any()
+        }
+    }
+}
+
+fn render_item(recipe: Arc<models::CooklangRecipe>, item: Item) -> impl IntoView {
+    match item {
+        Item::ItemOneOf(item_one_of) => {
+            // text
+            let text = item_one_of.text;
+            view! { {text.value} }.into_any()
+        }
+        Item::ItemOneOf1(item_one_of1) => {
+            // Ingredient
+            let ingr = recipe
+                .ingredients
+                .get(item_one_of1.ingredient.index as usize)
+                .expect("to be valid, as cooklang parser should have varified it")
+                .to_owned();
+
+            render_ingredient_text(ingr).into_any()
+        }
+        Item::ItemOneOf2(item_one_of2) => {
+            // Cookware index
+            let cookware = recipe
+                .cookware
+                .get(item_one_of2.cookware.index as usize)
+                .expect("to be valid, as cooklang parser should have varified it")
+                .to_owned();
+
+            view! {
+                <span class="bg-green-400/50 rounded">
+                    {if let Some(qty) = cookware.quantity {
+                        format!("{} x {}", qty, cookware.name)
+                    } else {
+                        cookware.name
+                    }}
+                </span>
+            }
+            .into_any()
+        }
+        Item::ItemOneOf3(item_one_of3) => {
+            // Timer index
+            let timer = recipe
+                .timers
+                .get(item_one_of3.timer.index as usize)
+                .expect("to be valid, as cooklang parser should have varified it")
+                .to_owned();
+
+            let amount = timer.quantity.map_or(().into_any(), |amount| {
+                render_unit_amount_text(amount).into_any()
+            });
+
+            let name = timer.name.unwrap_or(String::from("<Unnamed timer>"));
+
+            view! { <span class="bg-blue-400/50 rounded">{name}{amount}</span> }.into_any()
+        }
+        Item::ItemOneOf4(item_one_of4) => {
+            // InlineQuantity
+            todo!("Inline quantity not yet supported")
+        }
+    }
+}
+
+fn render_unit_amount_text(amount: UnitAmount) -> impl IntoView {
+    AsyncFetch! {
+        @map_error_in_producer
+        fetcher = unit_by_id_wrapped(amount.unit),
+        producer = |unit| {
+            view! {
+                {
+                    format!(" ({} {})", amount.value, unit.short_name)
+                }
+            }
+        }
+    }
+}
+
+fn render_ingredient_text(ingr: Ingredient) -> impl IntoView {
+    match ingr.clone() {
+        Ingredient::IngredientOneOf(ingredient_one_of) => {
+            // Registered product
+            AsyncFetch! {
+                @map_error_in_producer
+                fetcher = product_by_id_wrapped(ingredient_one_of.registered_product.id),
+                producer =|product| {
+                    let amount = ingredient_one_of
+                        .registered_product
+                        .quantity
+                        .map_or(().into_any(), |amount| render_unit_amount_text(amount.amount).into_any());
+
+                    view! {
+                        <span class="rounded bg-gray-300/70">
+                            <a href=format!("/product/{}", &product.name)>{product.name.clone()}</a>{amount}
+                        </span>
+                    }
+                }
+            }
+            .into_any()
+        }
+        Ingredient::IngredientOneOf1(ingredient_one_of1) => {
+            // Not registered product
+            let amount = ingredient_one_of1
+                .not_registered_product
+                .quantity
+                .map_or(().into_any(), |amount| {
+                    render_unit_amount_text(amount).into_any()
+                });
+
+            view! {
+                <span class="rounded-lg bg-red-400/70">
+                    {ingredient_one_of1.not_registered_product.name}{amount}
+                </span>
+            }
+            .into_any()
+        }
+        Ingredient::IngredientOneOf2(ingredient_one_of2) => {
+            // Recipe reference
+            let id = ingredient_one_of2.recipe_reference.id;
+
+            AsyncFetch! {
+                @map_error_in_producer
+                fetcher=recipe_by_id_wrapped(id),
+                producer=|recipe| {
+                    view! {
+                        <span class="bg-blue-300/50 rounded-lg">
+                            <a href=format!("/recipe/{}", recipe.name)>{recipe.name.clone()}</a>
+                        </span>
+                    }
+                },
+            }
+            .into_any()
+        }
+    }
+}
+
+fn render_unit_amount_ingredient_list(amount: UnitAmount) -> impl IntoView {
+    AsyncFetch! {
+        @map_error_in_producer
+        fetcher = unit_by_id_wrapped(amount.unit),
+        producer = |unit| {
+            view! {
+                {
+                    format!(
+                        "{} {} of ",
+                        amount.value,
+                        if amount.value == 1 {unit.full_name_singular} else {unit.full_name_plural},
+                    )
+                }
+            }
+        }
+    }
+}
+
+fn render_ingredient_ingredient_list(ingr: Ingredient) -> impl IntoView {
+    match ingr.clone() {
+        Ingredient::IngredientOneOf(ingredient_one_of) => {
+            // Registered product
+            AsyncFetch! {
+                @map_error_in_producer
+                fetcher = product_by_id_wrapped(ingredient_one_of.registered_product.id),
+                producer = |product| {
+                    let amount = ingredient_one_of
+                        .registered_product
+                        .quantity
+                        .map_or(
+                            ().into_any(),
+                            |amount| render_unit_amount_ingredient_list(amount.amount).into_any());
+
+
+                    view! {
+                        <span id={product.name}>
+                            <a href={format!("/product/{}", &product.name)}>
+                                {amount}{product.name.clone()}
+                            </a>
+                        </span>
+                    }
+                }
+            }
+            .into_any()
+        }
+        Ingredient::IngredientOneOf1(ingredient_one_of1) => {
+            // Not registered product
+            let amount = ingredient_one_of1
+                .not_registered_product
+                .quantity
+                .map_or(().into_any(), |amount| {
+                    render_unit_amount_ingredient_list(amount).into_any()
+                });
+
+            view! {
+                <span class="rounded-lg bg-red-400/70">
+                   {amount}{ingredient_one_of1.not_registered_product.name}
+                </span>
+            }
+            .into_any()
+        }
+        Ingredient::IngredientOneOf2(ingredient_one_of2) => {
+            // Recipe reference
+            let id = ingredient_one_of2.recipe_reference.id;
+
+            AsyncFetch! {
+                @map_error_in_producer
+                fetcher=recipe_by_id_wrapped(id),
+                producer=|recipe| {
+                    view! {
+                        <span class="bg-blue-300/50 rounded-lg">
+                            <a href=format!("/recipe/{}", recipe.name)>{recipe.name.clone()}</a>
+                        </span>
+                    }
+                },
+            }
+            .into_any()
+        }
+    }
+}
diff --git a/src/pages/recipies.rs b/src/pages/recipies.rs
index 1fc9dcc..c372d9b 100644
--- a/src/pages/recipies.rs
+++ b/src/pages/recipies.rs
@@ -1,8 +1,76 @@
-use leptos::{IntoView, component, view};
+use leptos::{
+    IntoView, component,
+    prelude::{ClassAttribute, ElementChild, OnAttribute},
+    view,
+};
+use leptos_router::{NavigateOptions, components::A, hooks::use_navigate};
+use log::info;
+use rocie_client::models::{Recipe, RecipeParent};
 
-use crate::components::site_header::SiteHeader;
+use crate::{
+    api::{
+        recipe_parents_toplevel_wrapped, recipe_parents_under_404_wrapped,
+        recipes_by_recipe_parent_id_direct_wrapped, recipes_by_recipe_parent_id_indirect_wrapped,
+        recipes_without_recipe_parent_wrapped,
+    },
+    components::{
+        async_fetch::{AsyncFetch, AsyncResource},
+        catch_errors::CatchErrors,
+        login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
+    pages::mk_render_parents,
+};
 
 #[component]
 pub fn Recipies() -> impl IntoView {
-    view! { <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" /> }
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || "/recipies".to_owned()>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" />
+                <ul class="flex flex-col p-2 m-2">
+                    {
+                        AsyncFetch! {
+                            @map_error_in_producer
+                            from_resource = AsyncResource!(
+                                () -> Result<(Vec<RecipeParent>, Vec<Recipe>), leptos::error::Error> {
+                                    Ok((
+                                        recipe_parents_toplevel_wrapped().await?,
+                                        recipes_without_recipe_parent_wrapped().await?
+                                    ))
+                                }
+                            ),
+                            producer = |(parents, toplevel_recipes)| {
+                                render_recipe_parents(Some(parents), Some(toplevel_recipes))
+                            },
+                        }
+                    }
+                </ul>
+            </LoginWall>
+        </CatchErrors>
+    }
+}
+
+mk_render_parents!(
+    self = render_recipe_parents,
+    parent_type = RecipeParent,
+    item_type = Recipe,
+    value_renderer = render_recipes,
+    under_parent_fetcher = recipe_parents_under_404_wrapped,
+    indirect_fetcher = recipes_by_recipe_parent_id_indirect_wrapped,
+    direct_fetcher = recipes_by_recipe_parent_id_direct_wrapped,
+);
+
+fn render_recipes(recipes: Vec<Recipe>) -> impl IntoView {
+    recipes
+        .into_iter()
+        .map(|recipe| {
+            let name = recipe.name.clone();
+            view! {
+                <li>
+                    <A href=move || format!("/recipe/{name}")>{recipe.name}</A>
+                </li>
+            }
+        })
+        .collect::<Vec<_>>()
 }
diff --git a/src/pages/units.rs b/src/pages/units.rs
new file mode 100644
index 0000000..a5d8655
--- /dev/null
+++ b/src/pages/units.rs
@@ -0,0 +1,78 @@
+use leptos::{
+    IntoView, component,
+    prelude::{ClassAttribute, CollectView, ElementChild},
+    view,
+};
+use rocie_client::models::{Unit, UnitPropertyId};
+
+use crate::{
+    api::{unit_properties_wrapped, units_by_property_id_wrapped},
+    components::{
+        async_fetch::{AsyncFetch, AsyncResource},
+        catch_errors::CatchErrors,
+        login_wall::LoginWall,
+        site_header::SiteHeader,
+    },
+};
+
+#[component]
+pub(crate) fn Units() -> impl IntoView {
+    view! {
+        <CatchErrors>
+            <LoginWall back=move || "/units".to_owned()>
+                <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Units" />
+
+                <ul class="flex flex-col gap-2 p-2 m-2">
+                    {
+                        AsyncFetch! {
+                            @map_error_in_producer
+                    fetcher = unit_properties_wrapped(),
+                    producer = |unit_properties| {
+                        unit_properties.into_iter().map(|unit_property| {
+                            let resource = AsyncResource!{
+                                (
+                                    unit_property_name: String = unit_property.name.clone(),
+                                    unit_property_id: UnitPropertyId = unit_property.id
+                                ) -> Result<(Vec<Unit>, String), leptos::error::Error> {
+                                    Ok(
+                                        (
+                                            units_by_property_id_wrapped(unit_property_id).await?,
+                                            unit_property_name
+                                        )
+                                    )
+                                }
+                            };
+
+                            AsyncFetch! {
+                                @map_error_in_producer
+                                from_resource = resource,
+                                producer = |(units, unit_property_name)| {
+                                    let units = units.into_iter().map(|unit| view!{
+                                        <li>
+                                            {format!("{} ({})", unit.full_name_singular, unit.short_name)}
+                                        </li>
+                                    }).collect::<Vec<_>>();
+
+
+                                    view! {
+                                        <li>
+                                            <div class="bg-gray-200 p-1 rounded-lg">
+                                                <p class="font-bold">{unit_property_name}</p>
+
+                                                <ul class="ml-4">
+                                                    {units}
+                                                </ul>
+                                            </div>
+                                        </li>
+                                    }
+                                }
+                            }
+                        }).collect_view()
+                    },
+                        }
+                    }
+                </ul>
+            </LoginWall>
+        </CatchErrors>
+    }
+}