diff --git a/.env b/.env index 3317a1f..44de5d2 100644 --- a/.env +++ b/.env @@ -22,3 +22,9 @@ WEB_HOST=0.0.0.0 FILES_PUBLIC_PATH=/files FILES_LOCAL_PATH=./tmp + +SONIC_SEARCH_ADDR=0.0.0.0:1491 +SONIC_SEARCH_PASS=SecretPassword +SONIC_INGEST_ADDR=0.0.0.0:1491 +SONIC_INGEST_PASS=SecretPassword +SEARCH_ACTIVE=true diff --git a/Cargo.lock b/Cargo.lock index 79d70fc..e2dfc98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4359,6 +4359,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "thiserror", "wasm-bindgen", "web-sys", ] diff --git a/actors/database_manager/src/products.rs b/actors/database_manager/src/products.rs index 61dc129..0473678 100644 --- a/actors/database_manager/src/products.rs +++ b/actors/database_manager/src/products.rs @@ -7,6 +7,7 @@ use model::{ }; use super::Result; +use crate::MultiLoad; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -22,6 +23,8 @@ pub enum Error { ShoppingCartProducts, #[error("Product with id {0} can't be found")] Single(model::ProductId), + #[error("Failed to load products for given ids")] + FindProducts, } #[derive(Message)] @@ -44,6 +47,7 @@ SELECT id, price, deliver_days_flag FROM products +ORDER BY id "#, ) .fetch_all(pool) @@ -256,6 +260,7 @@ SELECT products.id, FROM products INNER JOIN shopping_cart_items ON shopping_cart_items.product_id = products.id WHERE shopping_cart_id = $1 +ORDER BY products.id "#, ) .bind(msg.shopping_cart_id) @@ -266,3 +271,46 @@ WHERE shopping_cart_id = $1 crate::Error::Product(Error::ShoppingCartProducts) }) } + +#[derive(Message)] +#[rtype(result = "Result>")] +pub struct FindProducts { + pub product_ids: Vec, +} + +crate::db_async_handler!( + FindProducts, + find_products, + Vec, + inner_find_products +); + +pub(crate) async fn find_products( + msg: FindProducts, + pool: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result> { + MultiLoad::new( + pool, + r#" +SELECT id, + name, + short_description, + long_description, + category, + price, + deliver_days_flag +FROM products +WHERE + "#, + "products.id =", + ) + .load( + msg.product_ids.len(), + msg.product_ids.into_iter().map(|id| *id), + |e| { + log::error!("{e:?}"); + crate::Error::Product(Error::FindProducts) + }, + ) + .await +} diff --git a/actors/search_manager/src/lib.rs b/actors/search_manager/src/lib.rs index 68cd708..432cfb2 100644 --- a/actors/search_manager/src/lib.rs +++ b/actors/search_manager/src/lib.rs @@ -106,7 +106,7 @@ pub(crate) async fn search( } } } else { - Ok(Some(vec![])) + Ok(None) } } diff --git a/api/src/main.rs b/api/src/main.rs index 265eff3..e4656d5 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,6 +1,7 @@ #![feature(drain_filter)] use std::io::Write; +use std::str::FromStr; use actix::Actor; use actix_session::storage::RedisActorSessionStore; @@ -18,6 +19,8 @@ use opts::{ }; use validator::{validate_email, validate_length}; +use crate::opts::ReIndexOpts; + mod opts; pub mod routes; @@ -52,7 +55,7 @@ async fn server(opts: ServerOpts) -> Result<()> { .await .expect("Failed to start payment manager") .start(); - let search_manager = search_manager::SearchManager::new(app_config.clone()); + let search_manager = search_manager::SearchManager::new(app_config.clone()).start(); let fs_manager = fs_manager::FsManager::build(app_config.clone()) .await .expect("Failed to initialize file system storage"); @@ -164,8 +167,8 @@ async fn create_account(opts: CreateAccountOpts) -> Result<()> { .unwrap(); db.send(database_manager::CreateAccount { - email: Email::from(opts.email), - login: Login::from(opts.login), + email: Email::from_str(&opts.email).unwrap(), + login: Login::new(opts.login), pass_hash: PassHash::from(hash), role, }) @@ -193,6 +196,39 @@ async fn test_mailer(opts: TestMailerOpts) -> Result<()> { Ok(()) } +async fn reindex(opts: ReIndexOpts) -> Result<()> { + let config = config::default_load(&opts); + opts.update_config(&mut *config.lock()); + let db = database_manager::Database::build(config.clone()) + .await? + .start(); + let search = search_manager::SearchManager::new(config).start(); + let products: Vec = db + .send(database_manager::AllProducts) + .await + .unwrap() + .unwrap(); + for product in products { + search + .send(search_manager::CreateIndex { + key: product.id.to_string(), + value: vec![ + product.long_description.into_inner(), + product.short_description.into_inner(), + product.name.into_inner(), + ] + .join(" "), + collection: "products".into(), + lang: opts.lang.clone(), + }) + .await + .unwrap() + .unwrap(); + } + println!("Success!"); + Ok(()) +} + #[actix_web::main] async fn main() -> Result<()> { human_panic::setup_panic!(); @@ -211,5 +247,6 @@ async fn main() -> Result<()> { config::config_info().await.unwrap(); Ok(()) } + Command::ReIndex(opts) => reindex(opts).await, } } diff --git a/api/src/opts.rs b/api/src/opts.rs index 4dcc30f..3808466 100644 --- a/api/src/opts.rs +++ b/api/src/opts.rs @@ -44,6 +44,8 @@ pub enum Command { TestMailer(TestMailerOpts), #[options(help = "Print config information")] ConfigInfo(ConfigInfo), + #[options(help = "Perform all search indexing")] + ReIndex(ReIndexOpts), } impl UpdateConfig for Command { @@ -64,7 +66,7 @@ impl UpdateConfig for Command { Command::TestMailer(opts) => { opts.update_config(config); } - Command::ConfigInfo(_) => {} + Command::ReIndex(..) | Command::ConfigInfo(..) => {} } } } @@ -75,6 +77,14 @@ impl Default for Command { } } +#[derive(Options, Debug)] +pub struct ReIndexOpts { + pub help: bool, + pub lang: String, +} + +impl UpdateConfig for ReIndexOpts {} + #[derive(Options, Debug)] pub struct ConfigInfo { pub help: bool, @@ -85,9 +95,7 @@ pub struct GenerateHashOpts { pub help: bool, } -impl UpdateConfig for GenerateHashOpts { - fn update_config(&self, _config: &mut AppConfig) {} -} +impl UpdateConfig for GenerateHashOpts {} #[derive(Options, Debug)] pub struct ServerOpts { diff --git a/api/src/routes/admin/api_v1/products.rs b/api/src/routes/admin/api_v1/products.rs index 10b62a4..b2e88e7 100644 --- a/api/src/routes/admin/api_v1/products.rs +++ b/api/src/routes/admin/api_v1/products.rs @@ -109,8 +109,13 @@ async fn create_product( ); search.do_send(search_manager::CreateIndex { - key: format!("{}", product.id), - value: product.long_description.to_string(), + key: product.id.to_string(), + value: vec![ + product.long_description.to_string(), + product.short_description.to_string(), + product.name.to_string(), + ] + .join(" "), collection: "products".into(), lang: payload.lang, }); diff --git a/api/src/routes/public/api_v1/unrestricted.rs b/api/src/routes/public/api_v1/unrestricted.rs index 946c53a..8320f9f 100644 --- a/api/src/routes/public/api_v1/unrestricted.rs +++ b/api/src/routes/public/api_v1/unrestricted.rs @@ -1,16 +1,58 @@ use actix::Addr; -use actix_web::web::{Data, Json, Path, ServiceConfig}; +use actix_web::web::{Data, Json, Path, Query, ServiceConfig}; use actix_web::{get, post, HttpResponse}; use config::SharedAppConfig; use database_manager::{query_db, Database}; -use model::{api, AccessTokenString, Audience, Encrypt, FullAccount, RefreshTokenString, Token}; +use model::{api, Encrypt}; use payment_manager::{PaymentManager, PaymentNotification}; +use search_manager::SearchManager; use token_manager::{query_tm, TokenManager}; use crate::public_send_db; use crate::routes::public::Error as PublicError; use crate::routes::{self, Result}; +#[get("/search")] +async fn search( + db: Data>, + _config: Data, + search: Data>, + query: Query, +) -> routes::Result>> { + let q = query.into_inner(); + let product_ids: Vec = match search + .send(search_manager::Search { + query: q.q, + collection: "products".into(), + lang: q.lang, + }) + .await + { + Ok(Ok(Some(res))) => res + .into_iter() + .filter_map(|s| { + s.parse::() + .ok() + .map(model::ProductId::from) + }) + .collect(), + Ok(Ok(None)) => return Ok(Json(vec![])), + Ok(Err(e)) => { + log::error!("{e}"); + return Ok(Json(vec![])); + } + Err(e) => { + log::error!("{e:?}"); + return Ok(Json(vec![])); + } + }; + Ok(Json(public_send_db!( + owned, + db, + database_manager::FindProducts { product_ids } + ))) +} + #[get("/products")] async fn products( db: Data>, @@ -117,15 +159,15 @@ pub async fn create_account( } pub(crate) struct AuthPair { - pub access_token: Token, - pub access_token_string: AccessTokenString, - pub _refresh_token: Token, - pub refresh_token_string: RefreshTokenString, + pub access_token: model::Token, + pub access_token_string: model::AccessTokenString, + pub _refresh_token: model::Token, + pub refresh_token_string: model::RefreshTokenString, } pub(crate) async fn create_auth_pair( tm: Data>, - account: FullAccount, + account: model::FullAccount, ) -> routes::Result { let (access_token, refresh_token) = query_tm!( multi, @@ -135,19 +177,21 @@ pub(crate) async fn create_auth_pair( customer_id: account.customer_id, role: account.role, subject: account.id, - audience: Some(Audience::Web), + audience: Some(model::Audience::Web), exp: None }, token_manager::CreateToken { customer_id: account.customer_id, role: account.role, subject: account.id, - audience: Some(Audience::Web), + audience: Some(model::Audience::Web), exp: Some((chrono::Utc::now() + chrono::Duration::days(31)).naive_utc()) } ); - let (access_token, access_token_string): (Token, AccessTokenString) = access_token?; - let (refresh_token, refresh_token_string): (Token, AccessTokenString) = refresh_token?; + let (access_token, access_token_string): (model::Token, model::AccessTokenString) = + access_token?; + let (refresh_token, refresh_token_string): (model::Token, model::AccessTokenString) = + refresh_token?; Ok(AuthPair { access_token, access_token_string, @@ -164,7 +208,7 @@ async fn sign_in( ) -> Result> { let db = db.into_inner(); - let account: FullAccount = query_db!( + let account: model::FullAccount = query_db!( db, database_manager::AccountByIdentity { login: Some(payload.login), @@ -205,6 +249,7 @@ async fn handle_notification( pub(crate) fn configure(config: &mut ServiceConfig) { config + .service(search) .service(product) .service(products) .service(stocks) diff --git a/db-seed/src/products.rs b/db-seed/src/products.rs index 5bc1b23..34805c3 100644 --- a/db-seed/src/products.rs +++ b/db-seed/src/products.rs @@ -114,7 +114,13 @@ pub(crate) async fn create_products( "Lexal 128G", model::Category::MEMORY_NAME, None, - None + Some(r#"Nothing beats a freshly pulled pint in your favourite pub—except maybe a freshly pulled pint in your very own home. + +Never battle with crowds, struggle for a seat, or have to hang about outside on the pavement just to enjoy your favourite beer again! The Fizzics DraftPour gives you nitro-style draft beer from ANY can or bottle. Even the cheapest economy lager can be instantly transformed into a luxurious draft pint with just one pull of the lever. + +The DraftPour may be a sleek piece of kit, but it’s deceptively high tech under the hood, applying sound waves to convert your beer’s natural carbonation into a smooth micro-foam. These diddy little bubbles create the optimal density for enhanced aroma, flavour, and a silky smooth mouth-feel. + +Get a fruit machine and a few boxes of pork scratchings in and you’ve basically completely replicated your local pub. Sticky bar-top and ancient, dubiously-stained carpet not included."#) ), create_product( db.clone(), @@ -122,7 +128,7 @@ pub(crate) async fn create_products( "Fujifilm X-T10", model::Category::CAMERAS_NAME, None, - None + Some(r#"The Dauré family own one of the Roussillon’s top properties, the Château de Jau. Around the dinner table one Christmas they agreed it was time to spread their wings and look to new wine horizons. The womenfolk (Las Niñas) fancied Chile and won out in the end, achieving their dream when they established an estate in the Apalta Valley of Colchagua. The terroir is excellent and close neighbours of the Chilean star Montes winery."#) ), create_product( db.clone(), diff --git a/shared/config/src/lib.rs b/shared/config/src/lib.rs index cc5bbf5..63a201d 100644 --- a/shared/config/src/lib.rs +++ b/shared/config/src/lib.rs @@ -10,7 +10,7 @@ pub enum Error {} pub type Result = std::result::Result; pub trait UpdateConfig { - fn update_config(&self, config: &mut AppConfig); + fn update_config(&self, _config: &mut AppConfig) {} } trait Example: Sized { diff --git a/shared/model/src/api.rs b/shared/model/src/api.rs index f59be1c..89a99a8 100644 --- a/shared/model/src/api.rs +++ b/shared/model/src/api.rs @@ -363,6 +363,13 @@ pub struct CreateItemOutput { pub shopping_cart_item: ShoppingCartItem, } +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchRequest { + /// Match string + pub q: String, + pub lang: String, +} + pub mod admin { use serde::{Deserialize, Serialize}; diff --git a/shared/model/src/lib.rs b/shared/model/src/lib.rs index 3c4792e..45ce2f9 100644 --- a/shared/model/src/lib.rs +++ b/shared/model/src/lib.rs @@ -6,7 +6,7 @@ pub mod api; mod dummy; pub mod encrypt; -use std::fmt::Formatter; +use std::fmt::{Display, Formatter}; use std::str::FromStr; use derive_more::{Deref, Display, From}; @@ -319,6 +319,10 @@ impl FromStr for Email { Err(TransformError::NotEmail) } } + + fn invalid_empty() -> Self { + Self("".into()) + } } impl<'de> serde::Deserialize<'de> for Email { @@ -665,16 +669,32 @@ impl From for Account { #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, Deref, Display, From)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, Deref, From)] #[serde(transparent)] pub struct ProductId(RecordId); +impl Display for ProductId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.0)) + } +} + #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Clone, Hash, Deref, Display, From)] #[serde(transparent)] pub struct ProductName(String); +impl ProductName { + pub fn new>(s: S) -> Self { + Self(s.into()) + } + + pub fn into_inner(self) -> String { + self.0 + } +} + #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Clone, Hash, Deref, Display, From)] @@ -685,6 +705,10 @@ impl ProductShortDesc { pub fn new>(s: S) -> Self { Self(s.into()) } + + pub fn into_inner(self) -> String { + self.0 + } } #[cfg_attr(feature = "db", derive(sqlx::Type))] @@ -693,6 +717,12 @@ impl ProductShortDesc { #[serde(transparent)] pub struct ProductLongDesc(String); +impl ProductLongDesc { + pub fn into_inner(self) -> String { + self.0 + } +} + impl ProductLongDesc { pub fn new>(s: S) -> Self { Self(s.into()) diff --git a/web/Cargo.toml b/web/Cargo.toml index 26c4387..50c1a09 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -29,6 +29,8 @@ rusty-money = { version = "0.4.1", features = ["iso"] } pure-rust-locales = { version = "0.5.6" } +thiserror = { version = "1.0.31" } + [profile.release] lto = true opt-level = 's' diff --git a/web/src/lib.rs b/web/src/lib.rs index 6633a74..b9ee2bf 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -102,12 +102,21 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Msg::UrlChanged(subs::UrlChanged(url)) => model.page.page_changed(url, orders), Msg::Public(pages::public::Msg::Listing(msg)) => { let page = fetch_page!(public model, Listing); - pages::public::listing::update(msg, page, &mut orders.proxy(proxy_public_listing)); + pages::public::listing::update(msg, page, &mut orders.proxy(Into::into)); } 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)) + pages::public::product::update(msg, page, &mut orders.proxy(Into::into)) } + Msg::Public(pages::public::Msg::SignIn(msg)) => { + let page = fetch_page!(public model, SignIn); + pages::public::sign_in::update(msg, page, &mut orders.proxy(Into::into)) + } + Msg::Public(pages::public::Msg::SignUp(msg)) => { + let page = fetch_page!(public model, SignUp); + pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into)) + } + Msg::Admin(_) => {} } } @@ -115,6 +124,8 @@ fn view(model: &Model) -> Node { match &model.page { Page::Public(PublicPage::Listing(page)) => pages::public::listing::view(model, page), Page::Public(PublicPage::Product(page)) => pages::public::product::view(model, page), + Page::Public(PublicPage::SignIn(page)) => pages::public::sign_in::view(model, page), + Page::Public(PublicPage::SignUp(page)) => pages::public::sign_up::view(model, page), _ => empty![], } } @@ -123,11 +134,3 @@ fn view(model: &Model) -> Node { pub fn start() { App::start("main", init, update, view); } - -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/pages.rs b/web/src/pages.rs index 62a9c55..5717eab 100644 --- a/web/src/pages.rs +++ b/web/src/pages.rs @@ -9,6 +9,7 @@ use crate::shared; #[derive(Debug)] pub enum Msg { Public(public::Msg), + Admin(admin::Msg), UrlChanged(subs::UrlChanged), CheckAccessToken, Shared(shared::Msg), @@ -24,6 +25,8 @@ pub enum AdminPage { pub enum PublicPage { Listing(public::listing::ListingPage), Product(public::product::ProductPage), + SignIn(public::sign_in::SignInPage), + SignUp(public::sign_up::SignUpPage), ShoppingCart, Checkout, } @@ -38,20 +41,28 @@ impl Page { 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))), + &mut orders.proxy(Into::into), ))), ["products", _rest @ ..] => Self::Public(PublicPage::Listing(public::listing::init( url, - &mut orders.proxy(|msg| Msg::Public(public::Msg::Listing(msg))), + &mut orders.proxy(Into::into), ))), ["product", _rest @ ..] => Self::Public(PublicPage::Product(public::product::init( url, - &mut orders.proxy(|msg| Msg::Public(public::Msg::Product(msg))), + &mut orders.proxy(Into::into), + ))), + ["sign-in", _rest @ ..] => Self::Public(PublicPage::SignIn(public::sign_in::init( + url, + &mut orders.proxy(Into::into), + ))), + ["sign-up", _rest @ ..] => Self::Public(PublicPage::SignUp(public::sign_up::init( + url, + &mut orders.proxy(Into::into), ))), ["admin"] => Self::Admin(AdminPage::Landing), _ => Self::Public(PublicPage::Listing(public::listing::init( url, - &mut orders.proxy(|msg| Msg::Public(public::Msg::Listing(msg))), + &mut orders.proxy(Into::into), ))), } } @@ -70,6 +81,14 @@ impl Page { let page = crate::fetch_page!(public page self, Product, Page::init(url, orders)); public::product::page_changed(url, page); } + ["sign-in", ..] => { + let page = crate::fetch_page!(public page self, SignIn, Page::init(url, orders)); + public::sign_in::page_changed(url, page); + } + ["sign-up", ..] => { + let page = crate::fetch_page!(public page self, SignUp, Page::init(url, orders)); + public::sign_up::page_changed(url, page); + } ["admin"] => {} _ => {} } @@ -104,6 +123,14 @@ impl<'a> Urls<'a> { self.base_url().add_path_part("sign-in") } + pub fn sign_up(self) -> Url { + self.base_url().add_path_part("sign-up") + } + + pub fn forgot_password(self) -> Url { + self.base_url().add_path_part("forgot-password") + } + // Admin pub fn admin_landing(self) -> Url { self.base_url() diff --git a/web/src/pages/admin.rs b/web/src/pages/admin.rs index 111e028..e68ee21 100644 --- a/web/src/pages/admin.rs +++ b/web/src/pages/admin.rs @@ -1,2 +1,18 @@ +mod landing; + #[derive(Debug)] -pub enum Msg {} +pub enum Msg { + Landing(landing::Msg), +} + +impl From for Msg { + fn from(msg: landing::Msg) -> Self { + Self::Landing(msg) + } +} + +impl From for crate::Msg { + fn from(msg: Msg) -> Self { + crate::Msg::Admin(msg) + } +} diff --git a/web/src/pages/admin/landing.rs b/web/src/pages/admin/landing.rs new file mode 100644 index 0000000..2510e99 --- /dev/null +++ b/web/src/pages/admin/landing.rs @@ -0,0 +1,2 @@ +#[derive(Debug, thiserror::Error)] +pub enum Msg {} diff --git a/web/src/pages/public.rs b/web/src/pages/public.rs index afb2d57..4c37939 100644 --- a/web/src/pages/public.rs +++ b/web/src/pages/public.rs @@ -1,10 +1,68 @@ pub mod listing; pub mod product; +pub mod sign_in; +pub(crate) mod sign_up; #[derive(Debug)] pub enum Msg { Listing(listing::Msg), Product(product::Msg), + SignIn(sign_in::Msg), + SignUp(sign_up::Msg), +} + +impl From for Msg { + fn from(msg: listing::Msg) -> Self { + Self::Listing(msg) + } +} + +impl From for Msg { + fn from(msg: product::Msg) -> Self { + Self::Product(msg) + } +} + +impl From for Msg { + fn from(msg: sign_in::Msg) -> Self { + Self::SignIn(msg) + } +} + +impl From for Msg { + fn from(msg: sign_up::Msg) -> Self { + Self::SignUp(msg) + } +} + +impl From for crate::Msg { + fn from(msg: listing::Msg) -> Self { + crate::Msg::Public(msg.into()) + } +} + +impl From for crate::Msg { + fn from(msg: product::Msg) -> Self { + crate::Msg::Public(msg.into()) + } +} + +impl From for crate::Msg { + fn from(msg: sign_in::Msg) -> Self { + crate::Msg::Public(msg.into()) + } +} + +impl From for crate::Msg { + fn from(msg: sign_up::Msg) -> Self { + crate::Msg::Public(msg.into()) + } +} + +impl From for crate::Msg { + fn from(msg: Msg) -> Self { + crate::Msg::Public(msg) + } } pub mod layout { diff --git a/web/src/pages/public/listing.rs b/web/src/pages/public/listing.rs index b16463d..3cac86d 100644 --- a/web/src/pages/public/listing.rs +++ b/web/src/pages/public/listing.rs @@ -8,7 +8,7 @@ use crate::pages::Urls; #[derive(Debug)] pub struct ListingPage { - url: Url, + pub product_ids: Vec, pub products: HashMap, pub errors: Vec, pub categories: Vec, @@ -25,8 +25,8 @@ pub enum Msg { pub fn init(url: Url, orders: &mut impl Orders) -> ListingPage { orders.send_msg(Msg::FetchProducts); let model = ListingPage { - filters: url_to_filters(url.clone()), - url: url.set_path(&[] as &[&str]), + product_ids: vec![], + filters: url_to_filters(url), products: Default::default(), errors: vec![], categories: vec![], @@ -57,13 +57,15 @@ pub fn page_changed(url: Url, model: &mut ListingPage) { fn filter_products(model: &mut ListingPage) { model.visible_products = model - .products + .product_ids .iter() - .filter_map(|(_, p)| { - p.category - .as_ref() - .filter(|c| model.filters.contains(c.key.as_str())) - .map(|_| p.id) + .filter_map(|id| { + model.products.get(id).and_then(|p| { + p.category + .as_ref() + .filter(|c| model.filters.contains(c.key.as_str())) + .map(|_| p.id) + }) }) .collect(); } @@ -88,6 +90,7 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders) .into_iter() .collect(); model.categories.sort_by(|a, b| a.name.cmp(&b.name)); + model.product_ids = products.0.iter().map(|p| p.id).collect(); model.products = { let len = products.0.len(); products @@ -108,7 +111,11 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders) 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() + page.product_ids + .iter() + .filter_map(|id| page.products.get(id)) + .map(|p| product(model, p)) + .collect() } else { page.visible_products .iter() @@ -121,11 +128,11 @@ pub fn view(model: &crate::Model, page: &ListingPage) -> Node { 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))); + .map_msg(Into::into); div![ crate::shared::view::public_navbar(model), - super::layout::view(&model, content, Some(&page.categories)) + super::layout::view(model, content, Some(&page.categories)) ] } diff --git a/web/src/pages/public/product.rs b/web/src/pages/public/product.rs index b53f99d..64a6827 100644 --- a/web/src/pages/public/product.rs +++ b/web/src/pages/public/product.rs @@ -107,7 +107,7 @@ pub fn view(model: &crate::Model, page: &ProductPage) -> Node { attrs!["id" => "product-header"], div![description] ] - ].map_msg(map_to_global); + ].map_msg(Into::into); div![ crate::shared::view::public_navbar(model), @@ -187,7 +187,3 @@ fn image(img: &model::api::Photo) -> Node { img![C!["h-64 md:h-80"], 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/pages/public/sign_in.rs b/web/src/pages/public/sign_in.rs new file mode 100644 index 0000000..a7488cf --- /dev/null +++ b/web/src/pages/public/sign_in.rs @@ -0,0 +1,102 @@ +use seed::prelude::*; +use seed::*; + +use crate::pages::Urls; + +#[derive(Debug)] +pub enum Msg { + LoginChanged(String), + PasswordChanged(String), + Submit, +} + +#[derive(Debug)] +pub struct SignInPage { + pub login: model::Login, + pub password: model::Password, +} + +pub fn init(mut _url: Url, _orders: &mut impl Orders) -> SignInPage { + SignInPage { + login: model::Login::new(""), + password: model::Password::new(""), + } +} + +pub fn page_changed(_url: Url, _model: &mut SignInPage) {} + +pub fn update(_msg: Msg, _model: &mut SignInPage, _orders: &mut impl Orders) {} + +pub fn view(model: &crate::Model, page: &SignInPage) -> Node { + let content = div![ + C!["relative flex flex-col justify-center overflow-hidden"], + div![ + C!["w-full p-6 m-auto bg-white border-t-4 rounded-md shadow-md border-top lg:max-w-md"], + h1![C!["text-3xl font-semibold text-center text-indigo-700"], "Logo"], + sign_in_form(model, page), + p![ + C!["mt-8 text-xs font-light text-center text-indigo-700"], + model.i18n.t("Don't have an account?"), + a![ + C!["font-medium text-indigo-600 hover:underline"], + attrs![At::Href => Urls::new(model.url.clone().set_path(&[] as &[&str])).sign_up()], + " ", + model.i18n.t("Sign up") + ] + ] + ] + ] + .map_msg(Into::into); + + div![ + crate::shared::view::public_navbar(model), + super::layout::view(model, content, None) + ] +} + +fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node { + form![ + C!["mt-6"], + ev("submit", |ev| { + ev.stop_propagation(); + ev.prevent_default(); + Msg::Submit + }), + div![ + label![attrs!["for" => "login"], C!["block text-sm text-indigo-800"], model.i18n.t("Login")], + input![ + attrs!["type" => "text", "id" => "login"], + C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"], + ev(Ev::Change, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + ev.target().map(|target| seed::to_input(&target).value()).map(Msg::LoginChanged) + }) + ] + ], + div![ + C!["mt-4"], + div![ + label![attrs!["for" => "password"], C!["block text-sm text-indigo-800"], model.i18n.t("Password")], + input![attrs!["type" => "password", "id" => "password"], C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"]], + ev(Ev::Change, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordChanged) + }) + ], + a![ + C!["text-xs text-indigo-600 hover:underline"], + attrs![At::Href => Urls::new(model.url.clone().set_path(&[] as &[&str])).forgot_password()], + model.i18n.t("Forget Password?"), + ], + div![ + C!["mt-6"], + button![ + C!["w-full px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-indigo-700 rounded-md hover:bg-indigo-600 focus:outline-none focus:bg-indigo-600"], + model.i18n.t("Log in") + ] + ] + ] + ] +} diff --git a/web/src/pages/public/sign_up.rs b/web/src/pages/public/sign_up.rs new file mode 100644 index 0000000..0c32796 --- /dev/null +++ b/web/src/pages/public/sign_up.rs @@ -0,0 +1,113 @@ +use std::str::FromStr; + +use seed::prelude::*; +use seed::*; + +use crate::pages::Urls; + +#[derive(Debug)] +pub enum Msg { + LoginChanged(String), + EmailChanged(String), + PasswordChanged(String), + Submit, +} + +#[derive(Debug)] +pub struct SignUpPage { + pub login: model::Login, + pub email: model::Email, + pub password: model::Password, +} + +pub fn init(mut _url: Url, _orders: &mut impl Orders) -> SignUpPage { + SignUpPage { + login: model::Login::new(""), + email: model::Email::invalid_empty(), + password: model::Password::new(""), + } +} + +pub fn page_changed(_url: Url, _model: &mut SignUpPage) {} + +pub fn update(_msg: Msg, _model: &mut SignUpPage, _orders: &mut impl Orders) {} + +pub fn view(model: &crate::Model, page: &SignUpPage) -> Node { + let content = div![ + C!["relative flex flex-col justify-center overflow-hidden"], + div![ + C!["w-full p-6 m-auto bg-white border-t-4 rounded-md shadow-md border-top lg:max-w-md"], + h1![C!["text-3xl font-semibold text-center text-indigo-700"], "Logo"], + sign_up_form(model, page), + p![ + C!["mt-8 text-xs font-light text-center text-indigo-700"], + model.i18n.t("Have an account?"), + a![ + C!["font-medium text-indigo-600 hover:underline"], + attrs![At::Href => Urls::new(model.url.clone().set_path(&[] as &[&str])).sign_in()], + " ", + model.i18n.t("Sign in") + ] + ] + ] + ] + .map_msg(Into::into); + div![ + crate::shared::view::public_navbar(model), + super::layout::view(model, content, None) + ] +} + +fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node { + form![ + C!["mt-6"], + ev("submit", |ev| { + ev.stop_propagation(); + ev.prevent_default(); + Msg::Submit + }), + div![ + label![attrs!["for" => "email"], C!["block text-sm text-indigo-800"], model.i18n.t("E-Mail")], + input![ + attrs!["type" => "email", "id" => "email"], + C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"], + ev(Ev::Change, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + ev.target().map(|target| seed::to_input(&target).value()).map(Msg::EmailChanged) + }) + ] + ], + div![ + label![attrs!["for" => "login"], C!["block text-sm text-indigo-800"], model.i18n.t("Login")], + input![ + attrs!["type" => "text", "id" => "login"], + C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"], + ev(Ev::Change, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + ev.target().map(|target| seed::to_input(&target).value()).map(Msg::LoginChanged) + }) + ] + ], + div![ + C!["mt-4"], + div![ + label![attrs!["for" => "password"], C!["block text-sm text-indigo-800"], model.i18n.t("Password")], + input![attrs!["type" => "password", "id" => "password"], C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"]], + ev(Ev::Change, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordChanged) + }) + ], + div![ + C!["mt-6"], + button![ + C!["w-full px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-indigo-700 rounded-md hover:bg-indigo-600 focus:outline-none focus:bg-indigo-600"], + model.i18n.t("Register") + ] + ] + ] + ] +}