diff --git a/Cargo.lock b/Cargo.lock index 87c1283..9eb91f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,6 +641,7 @@ dependencies = [ "futures-util", "gumdrop", "human-panic", + "include_dir", "jemallocator", "log", "messagebus", @@ -1900,6 +1901,25 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "include_dir" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482a2e29200b7eed25d7fdbd14423326760b7f6658d21a4cf12d55a50713c69f" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e074c19deab2501407c91ba1860fa3d6820bfde307db6d8cb851b55a10be89b" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.8.1" diff --git a/actors/database_manager/src/products.rs b/actors/database_manager/src/products.rs index 5920cb1..61dc129 100644 --- a/actors/database_manager/src/products.rs +++ b/actors/database_manager/src/products.rs @@ -20,6 +20,8 @@ pub enum Error { Delete, #[error("Unable to find products for shopping cart")] ShoppingCartProducts, + #[error("Product with id {0} can't be found")] + Single(model::ProductId), } #[derive(Message)] @@ -52,6 +54,40 @@ FROM products }) } +#[derive(Message)] +#[rtype(result = "Result")] +pub struct FindProduct { + pub product_id: model::ProductId, +} + +crate::db_async_handler!(FindProduct, find_product, Product, inner_find_product); + +pub(crate) async fn find_product<'e, E>(msg: FindProduct, pool: E) -> Result +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + sqlx::query_as( + r#" +SELECT id, + name, + short_description, + long_description, + category, + price, + deliver_days_flag +FROM products +WHERE id = $1 + "#, + ) + .bind(msg.product_id) + .fetch_one(pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + crate::Error::Product(Error::Single(msg.product_id)) + }) +} + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[derive(Message, Debug)] #[rtype(result = "Result")] diff --git a/api/Cargo.toml b/api/Cargo.toml index 172931d..930f172 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -68,5 +68,7 @@ async-trait = { version = "0.1", features = [] } jemallocator = { version = "0.3", features = [] } +include_dir = { version = "0.7.2", features = [] } + # For rewrite into bus-based app messagebus = { version = "0.9.13" } diff --git a/api/assets/svg/cameras.svg b/api/assets/svg/cameras.svg new file mode 100644 index 0000000..02461a5 --- /dev/null +++ b/api/assets/svg/cameras.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/clothes.svg b/api/assets/svg/clothes.svg new file mode 100644 index 0000000..9a35c7f --- /dev/null +++ b/api/assets/svg/clothes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/drugstore.svg b/api/assets/svg/drugstore.svg new file mode 100644 index 0000000..a52af1f --- /dev/null +++ b/api/assets/svg/drugstore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/fruits.svg b/api/assets/svg/fruits.svg new file mode 100644 index 0000000..6a5e413 --- /dev/null +++ b/api/assets/svg/fruits.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/memory.svg b/api/assets/svg/memory.svg new file mode 100644 index 0000000..8e96530 --- /dev/null +++ b/api/assets/svg/memory.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/assets/svg/pants.svg b/api/assets/svg/pants.svg new file mode 100644 index 0000000..d0926d4 --- /dev/null +++ b/api/assets/svg/pants.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/phones.svg b/api/assets/svg/phones.svg new file mode 100644 index 0000000..c34cb62 --- /dev/null +++ b/api/assets/svg/phones.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/speakers.svg b/api/assets/svg/speakers.svg new file mode 100644 index 0000000..3df1bf9 --- /dev/null +++ b/api/assets/svg/speakers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/sweets.svg b/api/assets/svg/sweets.svg new file mode 100644 index 0000000..a1e41fe --- /dev/null +++ b/api/assets/svg/sweets.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/assets/svg/vegetables.svg b/api/assets/svg/vegetables.svg new file mode 100644 index 0000000..0aa187b --- /dev/null +++ b/api/assets/svg/vegetables.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/src/routes/public.rs b/api/src/routes/public.rs index 9b066cc..c2ade6e 100644 --- a/api/src/routes/public.rs +++ b/api/src/routes/public.rs @@ -1,6 +1,6 @@ pub mod api_v1; -use actix_web::web::ServiceConfig; +use actix_web::web::{Path, ServiceConfig}; use actix_web::{get, HttpResponse}; pub use api_v1::{Error as V1Error, ShoppingCartError as V1ShoppingCartError}; @@ -62,6 +62,35 @@ async fn pay_on_site() -> HttpResponse { HttpResponse::Ok().body("

Pay on Site

") } -pub fn configure(config: &mut ServiceConfig) { - config.service(landing).configure(api_v1::configure); +macro_rules! serve_svg { + ($name: expr) => { + HttpResponse::Ok() + .append_header(("Content-Type", "image/svg+xml")) + .body(include_bytes!(concat!("../../assets/svg/", $name, ".svg")).to_vec()) + }; +} + +#[get("/svg/{file}.svg")] +async fn svg(path: Path) -> HttpResponse { + let p = path.into_inner(); + match p.as_str() { + "cameras" => serve_svg!("cameras"), + "clothes" => serve_svg!("clothes"), + "drugstore" => serve_svg!("drugstore"), + "fruits" => serve_svg!("fruits"), + "pants" => serve_svg!("pants"), + "phones" => serve_svg!("phones"), + "speakers" => serve_svg!("speakers"), + "sweets" => serve_svg!("sweets"), + "vegetables" => serve_svg!("vegetables"), + "memory" => serve_svg!("memory"), + _ => HttpResponse::NotFound().finish(), + } +} + +pub fn configure(config: &mut ServiceConfig) { + config + .service(landing) + .service(svg) + .configure(api_v1::configure); } diff --git a/api/src/routes/public/api_v1/unrestricted.rs b/api/src/routes/public/api_v1/unrestricted.rs index 6d5a222..b4329d5 100644 --- a/api/src/routes/public/api_v1/unrestricted.rs +++ b/api/src/routes/public/api_v1/unrestricted.rs @@ -1,5 +1,5 @@ use actix::Addr; -use actix_web::web::{Data, Json, ServiceConfig}; +use actix_web::web::{Data, Json, Path, ServiceConfig}; use actix_web::{get, post, HttpResponse}; use config::SharedAppConfig; use database_manager::{query_db, Database}; @@ -33,6 +33,31 @@ async fn products( Ok(Json((products, photos, public_path).into())) } +#[get("/product/{id}")] +async fn product( + path: Path, + db: Data>, + config: Data, +) -> Result> { + let product_id: model::ProductId = path.into_inner().into(); + let db = db.into_inner(); + let public_path = { + let l = config.lock(); + l.files().public_path() + }; + + let product: model::Product = + public_send_db!(owned, db, database_manager::FindProduct { product_id }); + let mut photos: Vec = public_send_db!( + owned, + db, + database_manager::PhotosForProducts { + product_ids: vec![product.id] + } + ); + Ok(Json((product, &mut photos, public_path.as_str()).into())) +} + #[get("/stocks")] async fn stocks(db: Data>) -> Result { public_send_db!(db.into_inner(), database_manager::AllStocks) @@ -158,5 +183,9 @@ async fn handle_notification( } pub(crate) fn configure(config: &mut ServiceConfig) { - config.service(products).service(stocks).service(sign_in); + config + .service(product) + .service(products) + .service(stocks) + .service(sign_in); } diff --git a/shared/model/src/api.rs b/shared/model/src/api.rs index b539f5d..b92a87f 100644 --- a/shared/model/src/api.rs +++ b/shared/model/src/api.rs @@ -161,6 +161,25 @@ pub struct Photo { pub unique_name: crate::UniqueName, } +#[cfg_attr(feature = "dummy", derive(fake::Dummy))] +#[derive(Clone, Debug, Hash, PartialOrd, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct Category { + pub name: String, + pub key: String, + pub svg: String, +} + +impl From<&crate::Category> for Category { + fn from(crate::Category { name, key, svg }: &crate::Category) -> Self { + Self { + name: (*name).into(), + key: (*key).into(), + svg: (*svg).into(), + } + } +} + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[derive(Serialize, Deserialize, Debug, Hash)] pub struct Product { @@ -168,7 +187,7 @@ pub struct Product { pub name: crate::ProductName, pub short_description: crate::ProductShortDesc, pub long_description: crate::ProductLongDesc, - pub category: Option, + pub category: Option, pub price: crate::Price, pub deliver_days_flag: crate::Days, pub photos: Vec, @@ -195,7 +214,15 @@ impl<'path> From<(crate::Product, &mut Vec, &'path str)> for name, short_description, long_description, - category, + category: category.and_then(|name| { + crate::CATEGORIES.iter().find_map(|c| { + if c.name == name.as_str() { + Some(Category::from(c)) + } else { + None + } + }) + }), price, deliver_days_flag, photos: photos diff --git a/shared/model/src/lib.rs b/shared/model/src/lib.rs index b94b97e..a93283a 100644 --- a/shared/model/src/lib.rs +++ b/shared/model/src/lib.rs @@ -31,6 +31,57 @@ pub enum TransformError { pub type RecordId = i32; +#[derive(Clone, Debug, Hash, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct Category { + pub name: &'static str, + pub key: &'static str, + pub svg: &'static str, +} + +pub const CATEGORIES: [Category; 8] = [ + Category { + name: "Cameras", + key: "cameras", + svg: "/svg/cameras.svg", + }, + Category { + name: "Drugstore", + key: "drugstore", + svg: "/svg/drugstore.svg", + }, + Category { + name: "Speakers", + key: "speakers", + svg: "/svg/speakers.svg", + }, + Category { + name: "Phones", + key: "phones", + svg: "/svg/phones.svg", + }, + Category { + name: "Sweets", + key: "sweets", + svg: "/svg/sweets.svg", + }, + Category { + name: "Memory", + key: "memory", + svg: "/svg/memory.svg", + }, + Category { + name: "Pants", + key: "pants", + svg: "/svg/pants.svg", + }, + Category { + name: "Clothes", + key: "clothes", + svg: "/svg/clothes.svg", + }, +]; + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(rename_all = "snake_case"))] @@ -398,18 +449,9 @@ where > { let value = >::decode(value)?; Ok(Days( - (0..9) + (0..7) .into_iter() - .filter_map(|n| { - eprintln!( - "d {} {} {} {:?}", - n, - 1 << n, - value & 1 << n, - Day::try_from(value & 1 << n).ok() - ); - Day::try_from(value & 1 << n).ok() - }) + .filter_map(|n| Day::try_from(value & 1 << n).ok()) .collect(), )) } diff --git a/web/Trunk.toml b/web/Trunk.toml index 46a048d..09d7f22 100644 --- a/web/Trunk.toml +++ b/web/Trunk.toml @@ -8,3 +8,7 @@ backend = "http://localhost:8080/api/v1" [[proxy]] rewrite = "/files" backend = "http://localhost:8080/files" + +[[proxy]] +rewrite = "/svg" +backend = "http://localhost:8080/svg" diff --git a/web/src/api/public.rs b/web/src/api/public.rs index b3f43f2..af586b8 100644 --- a/web/src/api/public.rs +++ b/web/src/api/public.rs @@ -11,6 +11,16 @@ pub async fn fetch_products() -> fetch::Result { .await } +pub async fn fetch_product(product_id: model::ProductId) -> fetch::Result { + Request::new(format!("/api/v1/product/{}", product_id)) + .method(Method::Get) + .fetch() + .await? + .check_status()? + .json() + .await +} + pub async fn fetch_me(access_token: AccessTokenString) -> fetch::Result { Request::new("/api/v1/me") .header(fetch::Header::bearer(access_token.as_str())) diff --git a/web/src/lib.rs b/web/src/lib.rs index cb133e2..b326e35 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -10,6 +10,7 @@ use seed::prelude::*; use crate::model::Model; use crate::pages::{Msg, Page, PublicPage}; +#[macro_export] macro_rules! fetch_page { (public $model: expr, $page: ident, $ret: expr) => {{ let p = match &mut $model.page { @@ -21,6 +22,26 @@ macro_rules! fetch_page { _ => return $ret, } }}; + (public $model: expr, $page: ident) => {{ + let p = match &mut $model.page { + crate::pages::Page::Public(p) => p, + _ => return, + }; + match p { + crate::pages::PublicPage::$page(p) => p, + _ => return, + } + }}; + (public page $page: expr, $page_name: ident) => {{ + let p = match $page { + crate::pages::Page::Public(p) => p, + _ => return, + }; + match p { + crate::pages::PublicPage::$page_name(p) => p, + _ => return, + } + }}; } fn init(url: Url, orders: &mut impl Orders) -> Model { @@ -29,11 +50,9 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { .subscribe(Msg::UrlChanged); Model { + url: url.clone().set_path(&[] as &[&str]), token: LocalStorage::get("auth-token").ok(), - page: Page::Public(PublicPage::Listing(pages::public::listing::init( - url, - &mut orders.proxy(proxy_public_listing), - ))), + page: Page::init(url, orders), logo: seed::document() .query_selector("link[rel=icon]") .ok() @@ -62,21 +81,22 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } } } - Msg::UrlChanged(subs::UrlChanged(url)) => model.page = Page::init(url, orders), - Msg::Public(pages::public::Msg::Listing(pages::public::listing::Msg::Shared(msg))) => { - shared::update(msg, &mut model.shared, orders); - } + Msg::UrlChanged(subs::UrlChanged(url)) => model.page.page_changed(url), Msg::Public(pages::public::Msg::Listing(msg)) => { - let page = fetch_page!(public model, Listing, ()); + let page = fetch_page!(public model, Listing); pages::public::listing::update(msg, page, &mut orders.proxy(proxy_public_listing)); } + Msg::Public(pages::public::Msg::Product(msg)) => { + let page = fetch_page!(public model, Product); + pages::public::product::update(msg, page, &mut orders.proxy(proxy_public_product)) + } } } fn view(model: &Model) -> Node { match &model.page { - Page::Public(PublicPage::Listing(page)) => pages::public::listing::view(model, page) - .map_msg(|msg| Msg::Public(pages::public::Msg::Listing(msg))), + Page::Public(PublicPage::Listing(page)) => pages::public::listing::view(model, page), + Page::Public(PublicPage::Product(page)) => pages::public::product::view(model, page), _ => empty![], } } @@ -89,3 +109,7 @@ pub fn start() { fn proxy_public_listing(msg: pages::public::listing::Msg) -> Msg { Msg::Public(pages::public::Msg::Listing(msg)) } + +fn proxy_public_product(msg: pages::public::product::Msg) -> Msg { + Msg::Public(pages::public::Msg::Product(msg)) +} diff --git a/web/src/model.rs b/web/src/model.rs index 84003e6..9ca6fc3 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -1,6 +1,9 @@ +use seed::Url; + use crate::Page; pub struct Model { + pub url: Url, pub token: Option, pub page: Page, pub logo: Option, diff --git a/web/src/pages.rs b/web/src/pages.rs index 3436618..e0a4786 100644 --- a/web/src/pages.rs +++ b/web/src/pages.rs @@ -22,8 +22,8 @@ pub enum AdminPage { } pub enum PublicPage { - Listing(public::listing::Model), - Product, + Listing(public::listing::ListingPage), + Product(public::product::ProductPage), ShoppingCart, Checkout, } @@ -34,19 +34,20 @@ pub enum Page { } impl Page { - pub fn init(mut url: Url, orders: &mut impl Orders) -> Self { - match url.remaining_path_parts().as_slice() { + pub fn init(url: Url, orders: &mut impl Orders) -> Self { + match url.clone().remaining_path_parts().as_slice() { [] => Self::Public(PublicPage::Listing(public::listing::init( url, &mut orders.proxy(|msg| Msg::Public(public::Msg::Listing(msg))), ))), - ["products", rest @ ..] => { - seed::log!(rest); - Self::Public(PublicPage::Listing(public::listing::init( - url, - &mut orders.proxy(|msg| Msg::Public(public::Msg::Listing(msg))), - ))) - } + ["products", _rest @ ..] => Self::Public(PublicPage::Listing(public::listing::init( + url, + &mut orders.proxy(|msg| Msg::Public(public::Msg::Listing(msg))), + ))), + ["product", _rest @ ..] => Self::Public(PublicPage::Product(public::product::init( + url, + &mut orders.proxy(|msg| Msg::Public(public::Msg::Product(msg))), + ))), ["admin"] => Self::Admin(AdminPage::Landing), _ => Self::Public(PublicPage::Listing(public::listing::init( url, @@ -54,36 +55,55 @@ impl Page { ))), } } -} -pub enum Filter { - All, + pub fn page_changed(&mut self, url: Url) { + match url.clone().remaining_path_parts().as_slice() { + [] => { + let page = crate::fetch_page!(public page self, Listing); + public::listing::page_changed(url, page); + } + ["products", _rest @ ..] => { + let page = crate::fetch_page!(public page self, Listing); + public::listing::page_changed(url, page); + } + ["page", ..] => { + let page = crate::fetch_page!(public page self, Product); + public::product::page_changed(url, page); + } + ["admin"] => {} + _ => {} + } + } } struct_urls!(); impl<'a> Urls<'a> { - fn home(self) -> Url { + pub fn home(self) -> Url { self.base_url() } // Public - fn listing(self) -> Url { + pub fn listing(self) -> Url { self.base_url().add_path_part("products") } - fn product(self) -> Url { + pub fn product(self) -> Url { self.base_url().add_path_part("product") } - fn shopping_cart(self) -> Url { + pub fn shopping_cart(self) -> Url { self.base_url().add_path_part("shopping-cart") } - fn checkout(self) -> Url { + pub fn checkout(self) -> Url { self.base_url().add_path_part("checkout") } + pub fn sign_in(self) -> Url { + self.base_url().add_path_part("sign-in") + } + // Admin pub fn admin_landing(self) -> Url { self.base_url() diff --git a/web/src/pages/public.rs b/web/src/pages/public.rs index 719cc66..59651de 100644 --- a/web/src/pages/public.rs +++ b/web/src/pages/public.rs @@ -1,15 +1,21 @@ pub mod listing; +pub mod product; #[derive(Debug)] pub enum Msg { Listing(listing::Msg), + Product(product::Msg), } pub mod layout { use seed::prelude::*; use seed::*; - pub fn view(url: Url, content: Node, categories: &[String]) -> Node { + pub fn view( + url: Url, + content: Node, + categories: &[model::api::Category], + ) -> Node { div![ C!["flex"], div![ @@ -27,10 +33,10 @@ pub mod sidebar { use crate::pages::Urls; - pub fn view(url: Url, categories: &[String]) -> Node { + pub fn view(url: Url, categories: &[model::api::Category]) -> Node { let categories = categories .iter() - .map(|category| item(url.clone(), category.as_str())); + .map(|category| item(url.clone(), category)); div![ C!["flex flex-col justify-between mt-6"], @@ -38,14 +44,20 @@ pub mod sidebar { ] } - fn item(url: Url, category: &str) -> Node { - let url = Urls::new(url).listing().add_path_part(category); + fn item(url: Url, category: &model::api::Category) -> Node { + let url = Urls::new(url) + .listing() + .add_path_part(category.key.as_str()); li![ C!["flex items-center px-4 py-2 text-gray-700 rounded-md"], a![ C!["flex items-center px-4 py-2 text-gray-700 rounded-md"], attrs!["href" => url], - span![C!["mx-4 font-medium"], category] + img![ + C!["w-6 h-6"], + attrs!["src" => category.svg.as_str(), "style" => ""] + ], + span![C!["mx-4 font-medium"], category.name.as_str()] ] ] } diff --git a/web/src/pages/public/listing.rs b/web/src/pages/public/listing.rs index b9dfa14..7344dfe 100644 --- a/web/src/pages/public/listing.rs +++ b/web/src/pages/public/listing.rs @@ -1,15 +1,18 @@ use std::collections::{HashMap, HashSet}; +use model::api::Product; use seed::app::Orders; use seed::prelude::*; use seed::*; +use crate::pages::Urls; + #[derive(Debug)] -pub struct Model { +pub struct ListingPage { url: Url, pub products: HashMap, pub errors: Vec, - pub categories: Vec, + pub categories: Vec, pub filters: HashSet, pub visible_products: Vec, } @@ -18,35 +21,55 @@ pub struct Model { pub enum Msg { FetchProducts, ProductFetched(fetch::Result), - Shared(crate::shared::Msg), } -pub fn init(url: Url, orders: &mut impl Orders) -> Model { - let filters = match url.clone().remaining_path_parts().as_slice() { - ["products", filters @ ..] => { - filters - .into_iter() - .fold(HashSet::with_capacity(filters.len()), |mut s, filter| { - s.insert(String::from(*filter)); - s - }) - } - _ => HashSet::new(), - }; +pub fn init(url: Url, orders: &mut impl Orders) -> ListingPage { orders.send_msg(Msg::FetchProducts); - let model = Model { - url: url.to_base_url(), + let model = ListingPage { + filters: url_to_filters(url.clone()), + url: url.set_path(&[] as &[&str]), products: Default::default(), errors: vec![], categories: vec![], - filters, visible_products: vec![], }; seed::log!(&model); model } -pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { +fn url_to_filters(mut url: Url) -> HashSet { + match url.remaining_path_parts().as_slice() { + ["products", filters @ ..] => { + filters + .iter() + .fold(HashSet::with_capacity(filters.len()), |mut s, filter| { + s.insert(String::from(*filter)); + s + }) + } + _ => HashSet::new(), + } +} + +pub fn page_changed(url: Url, model: &mut ListingPage) { + model.filters = url_to_filters(url); + filter_products(model) +} + +fn filter_products(model: &mut ListingPage) { + model.visible_products = model + .products + .iter() + .filter_map(|(_, p)| { + p.category + .as_ref() + .filter(|c| model.filters.contains(c.key.as_str())) + .map(|_| p.id) + }) + .collect(); +} + +pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders) { match msg { Msg::FetchProducts => { orders.skip().perform_cmd({ @@ -58,14 +81,14 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { .0 .iter() .fold(HashSet::new(), |mut set, p| { - if let Some(category) = p.category.as_deref() { - set.insert(String::from(category)); + if let Some(category) = p.category.as_ref().cloned() { + set.insert(category); } set }) .into_iter() .collect(); - model.categories.sort(); + model.categories.sort_by(|a, b| a.name.cmp(&b.name)); model.products = { let len = products.0.len(); products @@ -76,45 +99,38 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { m }) }; - model.visible_products = model - .products - .iter() - .filter_map(|(_, p)| { - p.category - .as_deref() - .filter(|c| model.filters.contains(*c)) - .map(|_| p.id) - }) - .collect(); + filter_products(model); } Msg::ProductFetched(Err(_e)) => { model.errors.push("Failed to load products".into()); } - Msg::Shared(_) => {} } } -pub fn view(model: &crate::Model, page: &Model) -> Node { - let products = page - .visible_products - .iter() - .filter_map(|id| page.products.get(id)) - .map(product); +pub fn view(model: &crate::Model, page: &ListingPage) -> Node { + let products: Vec> = if page.visible_products.is_empty() { + page.products.values().map(|p| product(model, p)).collect() + } else { + page.visible_products + .iter() + .filter_map(|id| page.products.get(id)) + .map(|p| product(model, p)) + .collect() + }; + + let content = div![ + C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"], + products + ] + .map_msg(|msg: Msg| crate::Msg::Public(super::Msg::Listing(msg))); div![ crate::shared::view::public_navbar(model), - super::layout::view( - page.url.clone(), - div![ - C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"], - products - ], - &page.categories - ) + super::layout::view(page.url.clone(), content, &page.categories) ] } -fn product(product: &model::api::Product) -> Node { +fn product(model: &crate::Model, product: &model::api::Product) -> Node { use rusty_money::{iso, Money}; let price = Money::from_minor(**product.price as i64, iso::PLN).to_string(); @@ -126,10 +142,15 @@ fn product(product: &model::api::Product) -> Node { .map(|photo| photo.url.as_str()) .unwrap_or_default(); + let url = Urls::new(model.url.clone()) + .product() + .add_path_part((*product.id as i32).to_string()); + div![ C!["w-full px-4 lg:px-0"], div![ C!["p-3 bg-white rounded shadow-md"], + a![attrs!["href" => url], div![ div![ C!["relative w-full mb-3 h-62 lg:mb-0"], @@ -153,11 +174,11 @@ fn product(product: &model::api::Product) -> Node { ], div![ C!["flex items-center justify-between"], - a![C!["px-6 py-2 text-sm text-white bg-indigo-500 rounded-lg outline-none hover:bg-indigo-600 ring-indigo-300"], "Add to cart"], + a![C!["px-6 py-2 text-sm text-white bg-indigo-500 rounded-lg outline-none hover:bg-indigo-600 ring-indigo-300"], "Add to Cart"], div![C!["mt-1 text-xl font-semibold"], price], ] ] - ] + ]] ] ] } diff --git a/web/src/pages/public/product.rs b/web/src/pages/public/product.rs new file mode 100644 index 0000000..514c3de --- /dev/null +++ b/web/src/pages/public/product.rs @@ -0,0 +1,84 @@ +use seed::prelude::*; +use seed::*; + +#[derive(Debug)] +pub enum Msg { + ProductFetched(fetch::Result), +} + +#[derive(Debug, Default)] +pub struct ProductPage { + pub product_id: Option, + pub product: Option, +} + +pub fn init(mut url: Url, orders: &mut impl Orders) -> ProductPage { + let product_id = match url.remaining_path_parts().as_slice() { + ["product", id] => id.parse::().unwrap_or_default().into(), + _ => return ProductPage::default(), + }; + orders.perform_cmd(async move { + Msg::ProductFetched(crate::api::public::fetch_product(product_id).await) + }); + ProductPage { + product_id: Some(product_id), + product: None, + } +} + +pub fn page_changed(url: Url, model: &mut ProductPage) {} + +pub fn update(msg: Msg, model: &mut ProductPage, orders: &mut impl Orders) { + match msg { + Msg::ProductFetched(Ok(product)) => { + model.product = Some(product); + } + Msg::ProductFetched(Err(e)) => { + seed::error!(e); + } + } +} + +pub fn view(_model: &crate::Model, page: &ProductPage) -> Node { + let product = match page.product.as_ref() { + None => return empty!(), + Some(product) => product, + }; + let images = product + .photos + .iter() + .enumerate() + .map(|(idx, img)| image(idx, img).map_msg(map_to_global)); + div![ + C!["max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6"], + div![ + C!["flex flex-col md:flex-row -mx-4"], + div![ + C!["md:flex-1 px-4"], attrs!["id" => "photos"], + div![ + attrs!["x-data" => "{ image: 1}"], + div![C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4"], images] + ] + ], + div![ + C!["md:flex-1 px-4"], attrs!["id" => "details"], + h2![ + C!["mb-2 leading-tight tracking-tight font-bold text-gray-800 text-2xl md:text-3xl"], + product.name.as_str() + ] + ] + ] + ] +} + +fn image(idx: usize, img: &model::api::Photo) -> Node { + div![ + C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4 flex items-center justify-center"], + attrs!["x-show" => format!("image == {}", idx + 1)], + img![attrs!["src" => img.url.as_str()]] + ] +} + +fn map_to_global(msg: Msg) -> crate::Msg { + crate::pages::Msg::Public(crate::pages::public::Msg::Product(msg)) +} diff --git a/web/src/shared/view.rs b/web/src/shared/view.rs index 4d464e6..a8854f0 100644 --- a/web/src/shared/view.rs +++ b/web/src/shared/view.rs @@ -1,23 +1,26 @@ use seed::prelude::*; use seed::*; -pub fn public_navbar(model: &crate::Model) -> Node { +use crate::pages::Urls; +use crate::Msg; + +pub fn public_navbar(model: &crate::Model) -> Node { header![ C!["container flex justify-around py-8 mx-auto bg-white"], - div![C!["flex items-center"], logo(model),], + div![C!["flex items-center"], logo(model)], div![ C!["items-center hidden space-x-8 lg:flex"], - navbar_item(div![C![""], "Home"], "/"), + navbar_item(div![C![""], "Home"], Urls::new(model.url.clone()).home()), ], div![ C!["flex items-center space-x-2"], - navbar_item(account(), "/sign-in"), - navbar_item(bag(), "/cart") + navbar_item(account(), Urls::new(model.url.clone()).sign_in()), + navbar_item(bag(), Urls::new(model.url.clone()).shopping_cart()) ] ] } -fn navbar_item(name: Node, path: &str) -> Node { +fn navbar_item(name: Node, path: Url) -> Node { a![ attrs!["href" => path], C!["px-4 py-2 font-semibold text-gray-600 rounded"], @@ -25,7 +28,7 @@ fn navbar_item(name: Node, path: &str) -> Node { ] } -fn logo(model: &crate::Model) -> Node { +fn logo(model: &crate::Model) -> Node { a![ attrs!["href" => "/"], match model.logo.as_deref() { @@ -38,7 +41,7 @@ fn logo(model: &crate::Model) -> Node { ] } -fn bag() -> Node { +fn bag() -> Node { svg![ attrs![ "width" => "32px", @@ -56,7 +59,7 @@ fn bag() -> Node { ] } -fn account() -> Node { +fn account() -> Node { svg![ attrs![ "xmlns" => "http://www.w3.org/2000/svg",