Sign in and sign up views

This commit is contained in:
Adrian Woźniak 2022-05-13 15:24:11 +02:00
parent cc0f47321e
commit 353cdd602a
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
22 changed files with 578 additions and 59 deletions

6
.env
View File

@ -22,3 +22,9 @@ WEB_HOST=0.0.0.0
FILES_PUBLIC_PATH=/files FILES_PUBLIC_PATH=/files
FILES_LOCAL_PATH=./tmp 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

1
Cargo.lock generated
View File

@ -4359,6 +4359,7 @@ dependencies = [
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
"thiserror",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
] ]

View File

@ -7,6 +7,7 @@ use model::{
}; };
use super::Result; use super::Result;
use crate::MultiLoad;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
@ -22,6 +23,8 @@ pub enum Error {
ShoppingCartProducts, ShoppingCartProducts,
#[error("Product with id {0} can't be found")] #[error("Product with id {0} can't be found")]
Single(model::ProductId), Single(model::ProductId),
#[error("Failed to load products for given ids")]
FindProducts,
} }
#[derive(Message)] #[derive(Message)]
@ -44,6 +47,7 @@ SELECT id,
price, price,
deliver_days_flag deliver_days_flag
FROM products FROM products
ORDER BY id
"#, "#,
) )
.fetch_all(pool) .fetch_all(pool)
@ -256,6 +260,7 @@ SELECT products.id,
FROM products FROM products
INNER JOIN shopping_cart_items ON shopping_cart_items.product_id = products.id INNER JOIN shopping_cart_items ON shopping_cart_items.product_id = products.id
WHERE shopping_cart_id = $1 WHERE shopping_cart_id = $1
ORDER BY products.id
"#, "#,
) )
.bind(msg.shopping_cart_id) .bind(msg.shopping_cart_id)
@ -266,3 +271,46 @@ WHERE shopping_cart_id = $1
crate::Error::Product(Error::ShoppingCartProducts) crate::Error::Product(Error::ShoppingCartProducts)
}) })
} }
#[derive(Message)]
#[rtype(result = "Result<Vec<model::Product>>")]
pub struct FindProducts {
pub product_ids: Vec<model::ProductId>,
}
crate::db_async_handler!(
FindProducts,
find_products,
Vec<Product>,
inner_find_products
);
pub(crate) async fn find_products(
msg: FindProducts,
pool: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Vec<model::Product>> {
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
}

View File

@ -106,7 +106,7 @@ pub(crate) async fn search(
} }
} }
} else { } else {
Ok(Some(vec![])) Ok(None)
} }
} }

View File

@ -1,6 +1,7 @@
#![feature(drain_filter)] #![feature(drain_filter)]
use std::io::Write; use std::io::Write;
use std::str::FromStr;
use actix::Actor; use actix::Actor;
use actix_session::storage::RedisActorSessionStore; use actix_session::storage::RedisActorSessionStore;
@ -18,6 +19,8 @@ use opts::{
}; };
use validator::{validate_email, validate_length}; use validator::{validate_email, validate_length};
use crate::opts::ReIndexOpts;
mod opts; mod opts;
pub mod routes; pub mod routes;
@ -52,7 +55,7 @@ async fn server(opts: ServerOpts) -> Result<()> {
.await .await
.expect("Failed to start payment manager") .expect("Failed to start payment manager")
.start(); .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()) let fs_manager = fs_manager::FsManager::build(app_config.clone())
.await .await
.expect("Failed to initialize file system storage"); .expect("Failed to initialize file system storage");
@ -164,8 +167,8 @@ async fn create_account(opts: CreateAccountOpts) -> Result<()> {
.unwrap(); .unwrap();
db.send(database_manager::CreateAccount { db.send(database_manager::CreateAccount {
email: Email::from(opts.email), email: Email::from_str(&opts.email).unwrap(),
login: Login::from(opts.login), login: Login::new(opts.login),
pass_hash: PassHash::from(hash), pass_hash: PassHash::from(hash),
role, role,
}) })
@ -193,6 +196,39 @@ async fn test_mailer(opts: TestMailerOpts) -> Result<()> {
Ok(()) 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<model::Product> = 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] #[actix_web::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
human_panic::setup_panic!(); human_panic::setup_panic!();
@ -211,5 +247,6 @@ async fn main() -> Result<()> {
config::config_info().await.unwrap(); config::config_info().await.unwrap();
Ok(()) Ok(())
} }
Command::ReIndex(opts) => reindex(opts).await,
} }
} }

View File

@ -44,6 +44,8 @@ pub enum Command {
TestMailer(TestMailerOpts), TestMailer(TestMailerOpts),
#[options(help = "Print config information")] #[options(help = "Print config information")]
ConfigInfo(ConfigInfo), ConfigInfo(ConfigInfo),
#[options(help = "Perform all search indexing")]
ReIndex(ReIndexOpts),
} }
impl UpdateConfig for Command { impl UpdateConfig for Command {
@ -64,7 +66,7 @@ impl UpdateConfig for Command {
Command::TestMailer(opts) => { Command::TestMailer(opts) => {
opts.update_config(config); 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)] #[derive(Options, Debug)]
pub struct ConfigInfo { pub struct ConfigInfo {
pub help: bool, pub help: bool,
@ -85,9 +95,7 @@ pub struct GenerateHashOpts {
pub help: bool, pub help: bool,
} }
impl UpdateConfig for GenerateHashOpts { impl UpdateConfig for GenerateHashOpts {}
fn update_config(&self, _config: &mut AppConfig) {}
}
#[derive(Options, Debug)] #[derive(Options, Debug)]
pub struct ServerOpts { pub struct ServerOpts {

View File

@ -109,8 +109,13 @@ async fn create_product(
); );
search.do_send(search_manager::CreateIndex { search.do_send(search_manager::CreateIndex {
key: format!("{}", product.id), key: product.id.to_string(),
value: product.long_description.to_string(), value: vec![
product.long_description.to_string(),
product.short_description.to_string(),
product.name.to_string(),
]
.join(" "),
collection: "products".into(), collection: "products".into(),
lang: payload.lang, lang: payload.lang,
}); });

View File

@ -1,16 +1,58 @@
use actix::Addr; 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 actix_web::{get, post, HttpResponse};
use config::SharedAppConfig; use config::SharedAppConfig;
use database_manager::{query_db, Database}; 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 payment_manager::{PaymentManager, PaymentNotification};
use search_manager::SearchManager;
use token_manager::{query_tm, TokenManager}; use token_manager::{query_tm, TokenManager};
use crate::public_send_db; use crate::public_send_db;
use crate::routes::public::Error as PublicError; use crate::routes::public::Error as PublicError;
use crate::routes::{self, Result}; use crate::routes::{self, Result};
#[get("/search")]
async fn search(
db: Data<Addr<Database>>,
_config: Data<SharedAppConfig>,
search: Data<Addr<SearchManager>>,
query: Query<model::api::SearchRequest>,
) -> routes::Result<Json<Vec<model::Product>>> {
let q = query.into_inner();
let product_ids: Vec<model::ProductId> = 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::<model::RecordId>()
.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")] #[get("/products")]
async fn products( async fn products(
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
@ -117,15 +159,15 @@ pub async fn create_account(
} }
pub(crate) struct AuthPair { pub(crate) struct AuthPair {
pub access_token: Token, pub access_token: model::Token,
pub access_token_string: AccessTokenString, pub access_token_string: model::AccessTokenString,
pub _refresh_token: Token, pub _refresh_token: model::Token,
pub refresh_token_string: RefreshTokenString, pub refresh_token_string: model::RefreshTokenString,
} }
pub(crate) async fn create_auth_pair( pub(crate) async fn create_auth_pair(
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
account: FullAccount, account: model::FullAccount,
) -> routes::Result<AuthPair> { ) -> routes::Result<AuthPair> {
let (access_token, refresh_token) = query_tm!( let (access_token, refresh_token) = query_tm!(
multi, multi,
@ -135,19 +177,21 @@ pub(crate) async fn create_auth_pair(
customer_id: account.customer_id, customer_id: account.customer_id,
role: account.role, role: account.role,
subject: account.id, subject: account.id,
audience: Some(Audience::Web), audience: Some(model::Audience::Web),
exp: None exp: None
}, },
token_manager::CreateToken { token_manager::CreateToken {
customer_id: account.customer_id, customer_id: account.customer_id,
role: account.role, role: account.role,
subject: account.id, subject: account.id,
audience: Some(Audience::Web), audience: Some(model::Audience::Web),
exp: Some((chrono::Utc::now() + chrono::Duration::days(31)).naive_utc()) exp: Some((chrono::Utc::now() + chrono::Duration::days(31)).naive_utc())
} }
); );
let (access_token, access_token_string): (Token, AccessTokenString) = access_token?; let (access_token, access_token_string): (model::Token, model::AccessTokenString) =
let (refresh_token, refresh_token_string): (Token, AccessTokenString) = refresh_token?; access_token?;
let (refresh_token, refresh_token_string): (model::Token, model::AccessTokenString) =
refresh_token?;
Ok(AuthPair { Ok(AuthPair {
access_token, access_token,
access_token_string, access_token_string,
@ -164,7 +208,7 @@ async fn sign_in(
) -> Result<Json<api::SignInOutput>> { ) -> Result<Json<api::SignInOutput>> {
let db = db.into_inner(); let db = db.into_inner();
let account: FullAccount = query_db!( let account: model::FullAccount = query_db!(
db, db,
database_manager::AccountByIdentity { database_manager::AccountByIdentity {
login: Some(payload.login), login: Some(payload.login),
@ -205,6 +249,7 @@ async fn handle_notification(
pub(crate) fn configure(config: &mut ServiceConfig) { pub(crate) fn configure(config: &mut ServiceConfig) {
config config
.service(search)
.service(product) .service(product)
.service(products) .service(products)
.service(stocks) .service(stocks)

View File

@ -114,7 +114,13 @@ pub(crate) async fn create_products(
"Lexal 128G", "Lexal 128G",
model::Category::MEMORY_NAME, model::Category::MEMORY_NAME,
None, 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 its deceptively high tech under the hood, applying sound waves to convert your beers 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 youve basically completely replicated your local pub. Sticky bar-top and ancient, dubiously-stained carpet not included."#)
), ),
create_product( create_product(
db.clone(), db.clone(),
@ -122,7 +128,7 @@ pub(crate) async fn create_products(
"Fujifilm X-T10", "Fujifilm X-T10",
model::Category::CAMERAS_NAME, model::Category::CAMERAS_NAME,
None, None,
None Some(r#"The Dauré family own one of the Roussillons 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( create_product(
db.clone(), db.clone(),

View File

@ -10,7 +10,7 @@ pub enum Error {}
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
pub trait UpdateConfig { pub trait UpdateConfig {
fn update_config(&self, config: &mut AppConfig); fn update_config(&self, _config: &mut AppConfig) {}
} }
trait Example: Sized { trait Example: Sized {

View File

@ -363,6 +363,13 @@ pub struct CreateItemOutput {
pub shopping_cart_item: ShoppingCartItem, pub shopping_cart_item: ShoppingCartItem,
} }
#[derive(Serialize, Deserialize, Debug)]
pub struct SearchRequest {
/// Match string
pub q: String,
pub lang: String,
}
pub mod admin { pub mod admin {
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -6,7 +6,7 @@ pub mod api;
mod dummy; mod dummy;
pub mod encrypt; pub mod encrypt;
use std::fmt::Formatter; use std::fmt::{Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
use derive_more::{Deref, Display, From}; use derive_more::{Deref, Display, From};
@ -319,6 +319,10 @@ impl FromStr for Email {
Err(TransformError::NotEmail) Err(TransformError::NotEmail)
} }
} }
fn invalid_empty() -> Self {
Self("".into())
}
} }
impl<'de> serde::Deserialize<'de> for Email { impl<'de> serde::Deserialize<'de> for Email {
@ -665,16 +669,32 @@ impl From<FullAccount> for Account {
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[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)] #[serde(transparent)]
pub struct ProductId(RecordId); 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", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Deserialize, Debug, Clone, Hash, Deref, Display, From)] #[derive(Serialize, Deserialize, Debug, Clone, Hash, Deref, Display, From)]
#[serde(transparent)] #[serde(transparent)]
pub struct ProductName(String); pub struct ProductName(String);
impl ProductName {
pub fn new<S: Into<String>>(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", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Deserialize, Debug, Clone, Hash, Deref, Display, From)] #[derive(Serialize, Deserialize, Debug, Clone, Hash, Deref, Display, From)]
@ -685,6 +705,10 @@ impl ProductShortDesc {
pub fn new<S: Into<String>>(s: S) -> Self { pub fn new<S: Into<String>>(s: S) -> Self {
Self(s.into()) Self(s.into())
} }
pub fn into_inner(self) -> String {
self.0
}
} }
#[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", derive(sqlx::Type))]
@ -693,6 +717,12 @@ impl ProductShortDesc {
#[serde(transparent)] #[serde(transparent)]
pub struct ProductLongDesc(String); pub struct ProductLongDesc(String);
impl ProductLongDesc {
pub fn into_inner(self) -> String {
self.0
}
}
impl ProductLongDesc { impl ProductLongDesc {
pub fn new<S: Into<String>>(s: S) -> Self { pub fn new<S: Into<String>>(s: S) -> Self {
Self(s.into()) Self(s.into())

View File

@ -29,6 +29,8 @@ rusty-money = { version = "0.4.1", features = ["iso"] }
pure-rust-locales = { version = "0.5.6" } pure-rust-locales = { version = "0.5.6" }
thiserror = { version = "1.0.31" }
[profile.release] [profile.release]
lto = true lto = true
opt-level = 's' opt-level = 's'

View File

@ -102,12 +102,21 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::UrlChanged(subs::UrlChanged(url)) => model.page.page_changed(url, orders), Msg::UrlChanged(subs::UrlChanged(url)) => model.page.page_changed(url, orders),
Msg::Public(pages::public::Msg::Listing(msg)) => { 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)); pages::public::listing::update(msg, page, &mut orders.proxy(Into::into));
} }
Msg::Public(pages::public::Msg::Product(msg)) => { Msg::Public(pages::public::Msg::Product(msg)) => {
let page = fetch_page!(public model, Product); 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<Msg> {
match &model.page { match &model.page {
Page::Public(PublicPage::Listing(page)) => pages::public::listing::view(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::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![], _ => empty![],
} }
} }
@ -123,11 +134,3 @@ fn view(model: &Model) -> Node<Msg> {
pub fn start() { pub fn start() {
App::start("main", init, update, view); 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))
}

View File

@ -9,6 +9,7 @@ use crate::shared;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
Public(public::Msg), Public(public::Msg),
Admin(admin::Msg),
UrlChanged(subs::UrlChanged), UrlChanged(subs::UrlChanged),
CheckAccessToken, CheckAccessToken,
Shared(shared::Msg), Shared(shared::Msg),
@ -24,6 +25,8 @@ pub enum AdminPage {
pub enum PublicPage { pub enum PublicPage {
Listing(public::listing::ListingPage), Listing(public::listing::ListingPage),
Product(public::product::ProductPage), Product(public::product::ProductPage),
SignIn(public::sign_in::SignInPage),
SignUp(public::sign_up::SignUpPage),
ShoppingCart, ShoppingCart,
Checkout, Checkout,
} }
@ -38,20 +41,28 @@ impl Page {
match url.clone().remaining_path_parts().as_slice() { match url.clone().remaining_path_parts().as_slice() {
[] => Self::Public(PublicPage::Listing(public::listing::init( [] => Self::Public(PublicPage::Listing(public::listing::init(
url, 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( ["products", _rest @ ..] => Self::Public(PublicPage::Listing(public::listing::init(
url, 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( ["product", _rest @ ..] => Self::Public(PublicPage::Product(public::product::init(
url, 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), ["admin"] => Self::Admin(AdminPage::Landing),
_ => Self::Public(PublicPage::Listing(public::listing::init( _ => Self::Public(PublicPage::Listing(public::listing::init(
url, 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)); let page = crate::fetch_page!(public page self, Product, Page::init(url, orders));
public::product::page_changed(url, page); 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"] => {} ["admin"] => {}
_ => {} _ => {}
} }
@ -104,6 +123,14 @@ impl<'a> Urls<'a> {
self.base_url().add_path_part("sign-in") 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 // Admin
pub fn admin_landing(self) -> Url { pub fn admin_landing(self) -> Url {
self.base_url() self.base_url()

View File

@ -1,2 +1,18 @@
mod landing;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg {} pub enum Msg {
Landing(landing::Msg),
}
impl From<landing::Msg> for Msg {
fn from(msg: landing::Msg) -> Self {
Self::Landing(msg)
}
}
impl From<Msg> for crate::Msg {
fn from(msg: Msg) -> Self {
crate::Msg::Admin(msg)
}
}

View File

@ -0,0 +1,2 @@
#[derive(Debug, thiserror::Error)]
pub enum Msg {}

View File

@ -1,10 +1,68 @@
pub mod listing; pub mod listing;
pub mod product; pub mod product;
pub mod sign_in;
pub(crate) mod sign_up;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
Listing(listing::Msg), Listing(listing::Msg),
Product(product::Msg), Product(product::Msg),
SignIn(sign_in::Msg),
SignUp(sign_up::Msg),
}
impl From<listing::Msg> for Msg {
fn from(msg: listing::Msg) -> Self {
Self::Listing(msg)
}
}
impl From<product::Msg> for Msg {
fn from(msg: product::Msg) -> Self {
Self::Product(msg)
}
}
impl From<sign_in::Msg> for Msg {
fn from(msg: sign_in::Msg) -> Self {
Self::SignIn(msg)
}
}
impl From<sign_up::Msg> for Msg {
fn from(msg: sign_up::Msg) -> Self {
Self::SignUp(msg)
}
}
impl From<listing::Msg> for crate::Msg {
fn from(msg: listing::Msg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<product::Msg> for crate::Msg {
fn from(msg: product::Msg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<sign_in::Msg> for crate::Msg {
fn from(msg: sign_in::Msg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<sign_up::Msg> for crate::Msg {
fn from(msg: sign_up::Msg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<Msg> for crate::Msg {
fn from(msg: Msg) -> Self {
crate::Msg::Public(msg)
}
} }
pub mod layout { pub mod layout {

View File

@ -8,7 +8,7 @@ use crate::pages::Urls;
#[derive(Debug)] #[derive(Debug)]
pub struct ListingPage { pub struct ListingPage {
url: Url, pub product_ids: Vec<model::ProductId>,
pub products: HashMap<model::ProductId, model::api::Product>, pub products: HashMap<model::ProductId, model::api::Product>,
pub errors: Vec<String>, pub errors: Vec<String>,
pub categories: Vec<model::api::Category>, pub categories: Vec<model::api::Category>,
@ -25,8 +25,8 @@ pub enum Msg {
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> ListingPage { pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> ListingPage {
orders.send_msg(Msg::FetchProducts); orders.send_msg(Msg::FetchProducts);
let model = ListingPage { let model = ListingPage {
filters: url_to_filters(url.clone()), product_ids: vec![],
url: url.set_path(&[] as &[&str]), filters: url_to_filters(url),
products: Default::default(), products: Default::default(),
errors: vec![], errors: vec![],
categories: vec![], categories: vec![],
@ -57,13 +57,15 @@ pub fn page_changed(url: Url, model: &mut ListingPage) {
fn filter_products(model: &mut ListingPage) { fn filter_products(model: &mut ListingPage) {
model.visible_products = model model.visible_products = model
.products .product_ids
.iter() .iter()
.filter_map(|(_, p)| { .filter_map(|id| {
p.category model.products.get(id).and_then(|p| {
.as_ref() p.category
.filter(|c| model.filters.contains(c.key.as_str())) .as_ref()
.map(|_| p.id) .filter(|c| model.filters.contains(c.key.as_str()))
.map(|_| p.id)
})
}) })
.collect(); .collect();
} }
@ -88,6 +90,7 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders<Msg>)
.into_iter() .into_iter()
.collect(); .collect();
model.categories.sort_by(|a, b| a.name.cmp(&b.name)); model.categories.sort_by(|a, b| a.name.cmp(&b.name));
model.product_ids = products.0.iter().map(|p| p.id).collect();
model.products = { model.products = {
let len = products.0.len(); let len = products.0.len();
products products
@ -108,7 +111,11 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders<Msg>)
pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> { pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> {
let products: Vec<Node<Msg>> = if page.visible_products.is_empty() { let products: Vec<Node<Msg>> = 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 { } else {
page.visible_products page.visible_products
.iter() .iter()
@ -121,11 +128,11 @@ pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> {
C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"], C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"],
products products
] ]
.map_msg(|msg: Msg| crate::Msg::Public(super::Msg::Listing(msg))); .map_msg(Into::into);
div![ div![
crate::shared::view::public_navbar(model), crate::shared::view::public_navbar(model),
super::layout::view(&model, content, Some(&page.categories)) super::layout::view(model, content, Some(&page.categories))
] ]
} }

View File

@ -107,7 +107,7 @@ pub fn view(model: &crate::Model, page: &ProductPage) -> Node<crate::Msg> {
attrs!["id" => "product-header"], attrs!["id" => "product-header"],
div![description] div![description]
] ]
].map_msg(map_to_global); ].map_msg(Into::into);
div![ div![
crate::shared::view::public_navbar(model), crate::shared::view::public_navbar(model),
@ -187,7 +187,3 @@ fn image(img: &model::api::Photo) -> Node<Msg> {
img![C!["h-64 md:h-80"], attrs!["src" => img.url.as_str()]] 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))
}

View File

@ -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<Msg>) -> 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<Msg>) {}
pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> {
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<Msg> {
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")
]
]
]
]
}

View File

@ -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<Msg>) -> 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<Msg>) {}
pub fn view(model: &crate::Model, page: &SignUpPage) -> Node<crate::Msg> {
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<Msg> {
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")
]
]
]
]
}