Shopping cart view, drop down shopping cart

This commit is contained in:
Adrian Woźniak 2022-05-16 16:08:14 +02:00
parent ad924abd3f
commit 4db6cf7327
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
17 changed files with 690 additions and 296 deletions

75
Cargo.lock generated
View File

@ -1570,8 +1570,6 @@ dependencies = [
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
@ -2055,26 +2053,10 @@ checksum = "19e1c899248e606fbfe68dcb31d8b0176ebab833b103824af31bddf4b7457494"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]]
name = "libssh2-sys"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b094a36eb4b8b8c8a7b4b8ae43b2944502be3e59cd87687595cf6b0a71b3f4ca"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.6"
@ -2225,15 +2207,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "minidom"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "332592c2149fc7dd40a64fc9ef6f0d65607284b474cef9817d1fc8c7e7b3608e"
dependencies = [
"quick-xml",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@ -2802,15 +2775,6 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26aab6b48e2590e4a64d1ed808749ba06257882b461d01ca71baeb747074a6dd"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.18"
@ -3200,42 +3164,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "seed"
version = "0.9.1"
source = "git+https://github.com/seed-rs/seed#689055ef4f466f28dca621c23f5e2df7438bd1bc"
dependencies = [
"console_error_panic_hook",
"cookie",
"dbg",
"enclose",
"futures",
"getrandom",
"gloo-file",
"gloo-timers",
"gloo-utils",
"indexmap",
"js-sys",
"rand",
"serde",
"serde_json",
"uuid 0.8.2",
"version_check 0.9.4",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "seed_heroicons"
version = "0.1.0"
source = "git+https://github.com/mh84/seed_heroicons.git#32569ac5ba9adcffb168fbdaac8b258e99e73d86"
dependencies = [
"git2",
"minidom",
"seed 0.9.1 (git+https://github.com/seed-rs/seed)",
]
[[package]]
name = "semver"
version = "0.9.0"
@ -4358,8 +4286,7 @@ dependencies = [
"js-sys",
"model",
"rusty-money",
"seed 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)",
"seed_heroicons",
"seed",
"serde",
"serde-wasm-bindgen",
"serde_json",

View File

@ -7,6 +7,7 @@ mod dummy;
pub mod encrypt;
use std::fmt::{Display, Formatter};
use std::ops;
use std::str::FromStr;
use derive_more::{Deref, DerefMut, Display, From};
@ -276,6 +277,14 @@ impl Default for Audience {
#[serde(transparent)]
pub struct Price(NonNegative);
impl ops::Mul<Quantity> for Price {
type Output = Self;
fn mul(self, rhs: Quantity) -> Self::Output {
Self(NonNegative(rhs.0 .0 * self.0 .0))
}
}
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))]
@ -283,6 +292,22 @@ pub struct Price(NonNegative);
#[serde(transparent)]
pub struct Quantity(NonNegative);
impl ops::Add for Quantity {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl ops::Sub for Quantity {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl TryFrom<i32> for Quantity {
type Error = TransformError;
@ -366,6 +391,22 @@ impl<'de> serde::Deserialize<'de> for Email {
#[serde(transparent)]
pub struct NonNegative(i32);
impl std::ops::Add for NonNegative {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl std::ops::Sub for NonNegative {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self((self.0 - rhs.0).max(0))
}
}
impl TryFrom<i32> for NonNegative {
type Error = TransformError;

View File

@ -10,7 +10,7 @@ crate-type = ["cdylib"]
model = { path = "../shared/model", features = ["dummy"] }
seed = { version = "0.9.1", features = [] }
seed_heroicons = { git = "https://github.com/mh84/seed_heroicons.git" }
#seed_heroicons = { git = "https://github.com/mh84/seed_heroicons.git" }
chrono = { version = "*", features = ["wasm-bindgen", "wasmbind"] }
gloo-timers = { version = "*", features = ["futures"] }

View File

@ -35,5 +35,10 @@ pub fn define(i18n: &mut I18n) {
"There was internal server error. Please try later",
"Wystąpił błąd, proszę spróbować później",
)
.define("Can't create account", "Adres e-mail i/lub login są zajęte");
.define("Can't create account", "Adres e-mail i/lub login są zajęte")
.define("Checkout", "Zapłać")
.define("Home", "Home")
.define("Account", "Profil")
.define("Shopping cart", "Koszyk")
.define("Qty:", "Ilość:");
}

View File

@ -83,6 +83,7 @@ fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
shared: shared::Model::default(),
i18n: I18n::load(),
cart: Default::default(),
#[cfg(debug_assertions)]
debug_modal: false,
};
@ -119,9 +120,9 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<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(pages::public::sign_up::Msg::AccountCreated(
res,
))) => {
Msg::Public(pages::public::Msg::SignUp(
pages::public::sign_up::RegisterMsg::AccountCreated(res),
)) => {
orders
.skip()
.send_msg(Msg::Session(SessionMsg::TokenRefreshed(res)));
@ -130,17 +131,21 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let page = fetch_page!(public model, SignUp);
pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into))
}
Msg::Public(pages::public::Msg::ShoppingCart(msg)) => {
let page = fetch_page!(public model, ShoppingCart);
pages::public::shopping_cart::update(msg, page, &mut orders.proxy(Into::into))
}
Msg::Admin(_) => {}
Msg::Session(msg) => {
session::update(msg, model, orders);
}
Msg::Cart(msg) => {
shopping_cart::update(msg, model, orders);
}
#[cfg(debug_assertions)]
Msg::Debug(msg) => {
debug::update(msg, model);
}
Msg::Cart(msg) => {
shopping_cart::update(msg, model, orders);
}
}
}
@ -150,6 +155,9 @@ fn view(model: &Model) -> Node<Msg> {
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),
Page::Public(PublicPage::ShoppingCart(page)) => {
pages::public::shopping_cart::view(model, page)
}
_ => empty![],
};

View File

@ -1,6 +1,8 @@
use std::collections::{HashMap, HashSet};
use seed::Url;
use crate::{I18n, Page, shopping_cart};
use crate::{shopping_cart, I18n, Page};
#[derive(Debug)]
pub struct Model {
@ -11,6 +13,51 @@ pub struct Model {
pub shared: crate::shared::Model,
pub i18n: I18n,
pub cart: shopping_cart::ShoppingCart,
#[cfg(debug_assertions)]
pub debug_modal: bool,
}
#[derive(Debug, Default)]
pub struct Products {
pub categories: Vec<model::api::Category>,
pub product_ids: Vec<model::ProductId>,
pub products: HashMap<model::ProductId, model::api::Product>,
}
impl Products {
pub fn update(&mut self, products: Vec<model::api::Product>) {
let len = products.len();
self.categories = products
.iter()
.fold(HashSet::with_capacity(len), |mut set, p| {
if let Some(category) = p.category.as_ref().cloned() {
set.insert(category);
}
set
})
.into_iter()
.collect();
self.categories.sort_by(|a, b| a.name.cmp(&b.name));
self.product_ids = products.iter().map(|p| p.id).collect();
self.products = {
let len = products.len();
products
.into_iter()
.fold(HashMap::with_capacity(len), |mut m, p| {
m.insert(p.id, p);
m
})
};
}
pub fn filter_product_ids<F>(&self, filter: F) -> Vec<model::ProductId>
where
F: Fn(&model::api::Product) -> bool,
{
self.product_ids
.iter()
.filter_map(|id| self.products.get(id).filter(|&p| filter(p)).map(|p| p.id))
.collect()
}
}

View File

@ -34,7 +34,7 @@ pub enum PublicPage {
Product(public::product::ProductPage),
SignIn(public::sign_in::SignInPage),
SignUp(public::sign_up::SignUpPage),
ShoppingCart,
ShoppingCart(public::shopping_cart::ShoppingCartPage),
Checkout,
}
@ -59,6 +59,9 @@ impl Page {
url,
&mut orders.proxy(Into::into),
))),
["shopping-cart", _rest @ ..] => Self::Public(PublicPage::ShoppingCart(
public::shopping_cart::init(url, &mut orders.proxy(Into::into)),
)),
["sign-in", _rest @ ..] => Self::Public(PublicPage::SignIn(public::sign_in::init(
url,
&mut orders.proxy(Into::into),
@ -89,6 +92,11 @@ impl Page {
let page = crate::fetch_page!(public page self, Product, Page::init(url, orders));
public::product::page_changed(url, page);
}
["shopping-cart", _rest @ ..] => {
let page =
crate::fetch_page!(public page self, ShoppingCart, Page::init(url, orders));
public::shopping_cart::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);
@ -131,6 +139,10 @@ impl<'a> Urls<'a> {
self.base_url().add_path_part("sign-in")
}
pub fn profile(self) -> Url {
self.base_url().add_path_part("profile")
}
pub fn sign_up(self) -> Url {
self.base_url().add_path_part("sign-up")
}

View File

@ -1,64 +1,78 @@
pub mod listing;
pub mod product;
pub mod shopping_cart;
pub mod sign_in;
pub(crate) mod sign_up;
pub mod sign_up;
#[derive(Debug)]
pub enum Msg {
Listing(listing::Msg),
Product(product::Msg),
SignIn(sign_in::Msg),
SignUp(sign_up::Msg),
Listing(listing::ListingMsg),
Product(product::ProductMsg),
SignIn(sign_in::LogInMsg),
SignUp(sign_up::RegisterMsg),
ShoppingCart(shopping_cart::ShoppingCartMsg),
}
impl From<listing::Msg> for Msg {
fn from(msg: listing::Msg) -> Self {
impl From<listing::ListingMsg> for Msg {
fn from(msg: listing::ListingMsg) -> Self {
Self::Listing(msg)
}
}
impl From<product::Msg> for Msg {
fn from(msg: product::Msg) -> Self {
impl From<product::ProductMsg> for Msg {
fn from(msg: product::ProductMsg) -> Self {
Self::Product(msg)
}
}
impl From<sign_in::Msg> for Msg {
fn from(msg: sign_in::Msg) -> Self {
impl From<sign_in::LogInMsg> for Msg {
fn from(msg: sign_in::LogInMsg) -> Self {
Self::SignIn(msg)
}
}
impl From<sign_up::Msg> for Msg {
fn from(msg: sign_up::Msg) -> Self {
impl From<sign_up::RegisterMsg> for Msg {
fn from(msg: sign_up::RegisterMsg) -> Self {
Self::SignUp(msg)
}
}
impl From<listing::Msg> for crate::Msg {
fn from(msg: listing::Msg) -> Self {
impl From<shopping_cart::ShoppingCartMsg> for Msg {
fn from(msg: shopping_cart::ShoppingCartMsg) -> Self {
Self::ShoppingCart(msg)
}
}
impl From<listing::ListingMsg> for crate::Msg {
fn from(msg: listing::ListingMsg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<product::Msg> for crate::Msg {
fn from(msg: product::Msg) -> Self {
impl From<product::ProductMsg> for crate::Msg {
fn from(msg: product::ProductMsg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<sign_in::Msg> for crate::Msg {
fn from(msg: sign_in::Msg) -> Self {
impl From<sign_in::LogInMsg> for crate::Msg {
fn from(msg: sign_in::LogInMsg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<sign_up::Msg> for crate::Msg {
fn from(msg: sign_up::Msg) -> Self {
impl From<sign_up::RegisterMsg> for crate::Msg {
fn from(msg: sign_up::RegisterMsg) -> Self {
crate::Msg::Public(msg.into())
}
}
impl From<shopping_cart::ShoppingCartMsg> for crate::Msg {
fn from(msg: shopping_cart::ShoppingCartMsg) -> Self {
Self::Public(msg.into())
}
}
impl From<Msg> for crate::Msg {
fn from(msg: Msg) -> Self {
crate::Msg::Public(msg)

View File

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use model::Quantity;
use seed::app::Orders;
@ -6,33 +6,31 @@ use seed::prelude::*;
use seed::*;
use crate::api::NetRes;
use crate::model::Products;
use crate::pages::Urls;
use crate::shopping_cart::CartMsg;
#[derive(Debug)]
pub struct ListingPage {
pub product_ids: Vec<model::ProductId>,
pub products: HashMap<model::ProductId, model::api::Product>,
pub errors: Vec<String>,
pub categories: Vec<model::api::Category>,
pub filters: HashSet<String>,
pub visible_products: Vec<model::ProductId>,
pub products: Products,
}
#[derive(Debug)]
pub enum Msg {
pub enum ListingMsg {
FetchProducts,
ProductFetched(NetRes<model::api::Products>),
ProductsFetched(NetRes<model::api::Products>),
}
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> ListingPage {
orders.send_msg(Msg::FetchProducts);
pub fn init(url: Url, orders: &mut impl Orders<ListingMsg>) -> ListingPage {
orders.send_msg(ListingMsg::FetchProducts);
ListingPage {
product_ids: vec![],
products: Products::default(),
filters: url_to_filters(url),
products: Default::default(),
errors: vec![],
categories: vec![],
visible_products: vec![],
}
}
@ -53,62 +51,41 @@ fn url_to_filters(mut url: Url) -> HashSet<String> {
pub fn page_changed(url: Url, model: &mut ListingPage) {
model.filters = url_to_filters(url);
filter_products(model)
let ids = {
model
.products
.filter_product_ids(|product| filter_product(&*model, product))
};
model.visible_products = ids;
}
fn filter_products(model: &mut ListingPage) {
model.visible_products = model
.product_ids
.iter()
.filter_map(|id| {
model.products.get(id).and_then(|p| {
p.category
fn filter_product(model: &ListingPage, product: &model::api::Product) -> bool {
product
.category
.as_ref()
.filter(|c| model.filters.contains(c.key.as_str()))
.map(|_| p.id)
})
})
.collect();
.is_some()
}
pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders<crate::Msg>) {
pub fn update(msg: ListingMsg, model: &mut ListingPage, orders: &mut impl Orders<crate::Msg>) {
match msg {
Msg::FetchProducts => {
ListingMsg::FetchProducts => {
orders.skip().perform_cmd({
async {
crate::Msg::Public(
Msg::ProductFetched(crate::api::public::fetch_products().await).into(),
ListingMsg::ProductsFetched(crate::api::public::fetch_products().await).into(),
)
}
});
}
Msg::ProductFetched(NetRes::Success(products)) => {
model.categories = products
.0
.iter()
.fold(HashSet::new(), |mut set, p| {
if let Some(category) = p.category.as_ref().cloned() {
set.insert(category);
ListingMsg::ProductsFetched(NetRes::Success(products)) => {
model.products.update(products.0);
let ids = model
.products
.filter_product_ids(|product| filter_product(model, product));
model.visible_products = ids;
}
set
})
.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
.0
.into_iter()
.fold(HashMap::with_capacity(len), |mut m, p| {
m.insert(p.id, p);
m
})
};
filter_products(model);
}
Msg::ProductFetched(NetRes::Error(_)) | Msg::ProductFetched(NetRes::Http(_)) => {
ListingMsg::ProductsFetched(NetRes::Error(_)) | ListingMsg::ProductsFetched(NetRes::Http(_)) => {
model.errors.push("Failed to load products".into());
}
}
@ -116,15 +93,16 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders<crate:
pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> {
let products: Vec<Node<crate::Msg>> = if page.visible_products.is_empty() {
page.product_ids
page.products
.product_ids
.iter()
.filter_map(|id| page.products.get(id))
.filter_map(|id| page.products.products.get(id))
.map(|p| product(model, p))
.collect()
} else {
page.visible_products
.iter()
.filter_map(|id| page.products.get(id))
.filter_map(|id| page.products.products.get(id))
.map(|p| product(model, p))
.collect()
};
@ -136,8 +114,8 @@ pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> {
.map_msg(Into::into);
div![
crate::shared::view::public_navbar(model),
super::layout::view(model, content, Some(&page.categories))
crate::shared::view::public_navbar::view(model, &page.products),
super::layout::view(model, content, Some(&page.products.categories))
]
}

View File

@ -4,54 +4,60 @@ use seed::*;
use crate::api::NetRes;
#[derive(Debug)]
pub enum Msg {
ProductFetched(NetRes<model::api::Product>),
pub enum ProductMsg {
ProductsFetched(NetRes<model::api::Products>),
SelectImage(usize),
}
#[derive(Debug, Default)]
pub struct ProductPage {
pub product_id: Option<model::ProductId>,
pub product: Option<model::api::Product>,
pub products: crate::model::Products,
pub selected_image: usize,
}
pub fn init(mut url: Url, orders: &mut impl Orders<Msg>) -> ProductPage {
pub fn init(mut url: Url, orders: &mut impl Orders<crate::Msg>) -> ProductPage {
let product_id = match url.remaining_path_parts().as_slice() {
["product", id] => id.parse::<model::RecordId>().unwrap_or_default().into(),
_ => return ProductPage::default(),
};
orders.perform_cmd(async move {
Msg::ProductFetched(crate::api::public::fetch_product(product_id).await)
crate::Msg::from(ProductMsg::ProductsFetched(
crate::api::public::fetch_products().await,
))
});
ProductPage {
product_id: Some(product_id),
product: None,
products: Default::default(),
selected_image: 0,
}
}
pub fn page_changed(_url: Url, _model: &mut ProductPage) {}
pub fn update(msg: Msg, model: &mut ProductPage, _orders: &mut impl Orders<crate::Msg>) {
pub fn update(msg: ProductMsg, model: &mut ProductPage, _orders: &mut impl Orders<crate::Msg>) {
match msg {
Msg::ProductFetched(NetRes::Success(product)) => {
model.product = Some(product);
ProductMsg::ProductsFetched(NetRes::Success(products)) => {
model.products.update(products.0);
}
Msg::ProductFetched(NetRes::Error(e)) => {
ProductMsg::ProductsFetched(NetRes::Error(e)) => {
seed::error!(e);
}
Msg::ProductFetched(NetRes::Http(e)) => {
ProductMsg::ProductsFetched(NetRes::Http(e)) => {
seed::error!(e);
}
Msg::SelectImage(selected) => {
ProductMsg::SelectImage(selected) => {
model.selected_image = selected;
}
}
}
pub fn view(model: &crate::Model, page: &ProductPage) -> Node<crate::Msg> {
let product = match page.product.as_ref() {
let product_id = match &page.product_id {
Some(id) => id,
_ => return empty![],
};
let product = match page.products.products.get(product_id) {
None => return empty!(),
Some(product) => product,
};
@ -115,12 +121,12 @@ pub fn view(model: &crate::Model, page: &ProductPage) -> Node<crate::Msg> {
].map_msg(Into::into);
div![
crate::shared::view::public_navbar(model),
super::layout::view(model, content, None)
crate::shared::view::public_navbar::view(model, &page.products),
super::layout::view(model, content, Some(&page.products.categories))
]
}
fn action_section(product: &model::api::Product, model: &crate::Model) -> Node<Msg> {
fn action_section(product: &model::api::Product, model: &crate::Model) -> Node<ProductMsg> {
if product.available {
div![
C!["flex py-4 space-x-4"],
@ -139,7 +145,7 @@ fn action_section(product: &model::api::Product, model: &crate::Model) -> Node<M
ev("click", move |ev| {
ev.prevent_default();
ev.stop_propagation();
None as Option<Msg>
None as Option<ProductMsg>
})
]
]
@ -148,7 +154,7 @@ fn action_section(product: &model::api::Product, model: &crate::Model) -> Node<M
}
}
fn delivery_available(product: &model::api::Product, model: &crate::Model) -> Node<Msg> {
fn delivery_available(product: &model::api::Product, model: &crate::Model) -> Node<ProductMsg> {
match product.deliver_days_flag.len() {
0 => return empty![],
7 => return div![model.i18n.t("Delivery all week")],
@ -167,7 +173,7 @@ fn delivery_available(product: &model::api::Product, model: &crate::Model) -> No
]
}
fn small_image(idx: usize, selected: usize, img: &model::api::Photo) -> Node<Msg> {
fn small_image(idx: usize, selected: usize, img: &model::api::Photo) -> Node<ProductMsg> {
div![
C!["flex-1 px-2"],
button![
@ -180,13 +186,13 @@ fn small_image(idx: usize, selected: usize, img: &model::api::Photo) -> Node<Msg
ev("click", move |ev| {
ev.prevent_default();
ev.stop_propagation();
Msg::SelectImage(idx)
ProductMsg::SelectImage(idx)
})
]
]
}
fn image(img: &model::api::Photo) -> Node<Msg> {
fn image(img: &model::api::Photo) -> Node<ProductMsg> {
div![
C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4 flex items-center justify-center"],
img![C!["h-64 md:h-80"], attrs!["src" => img.url.as_str()]]

View File

@ -0,0 +1,177 @@
use rusty_money::Money;
use seed::prelude::*;
use seed::*;
use crate::api::NetRes;
#[derive(Debug)]
pub enum ShoppingCartMsg {
ProductsFetched(NetRes<model::api::Products>),
}
#[derive(Debug)]
pub struct ShoppingCartPage {
pub products: crate::model::Products,
}
pub fn init(_url: Url, orders: &mut impl Orders<crate::Msg>) -> ShoppingCartPage {
orders.perform_cmd(async move {
crate::Msg::from(ShoppingCartMsg::ProductsFetched(
crate::api::public::fetch_products().await,
))
});
ShoppingCartPage {
products: Default::default(),
}
}
pub fn page_changed(_url: Url, _model: &mut ShoppingCartPage) {}
pub fn update(
msg: ShoppingCartMsg,
model: &mut ShoppingCartPage,
_orders: &mut impl Orders<crate::Msg>,
) {
match msg {
ShoppingCartMsg::ProductsFetched(NetRes::Success(products)) => {
model.products.update(products.0);
}
ShoppingCartMsg::ProductsFetched(NetRes::Error(e)) => {
seed::error!(e);
}
ShoppingCartMsg::ProductsFetched(NetRes::Http(e)) => {
seed::error!(e);
}
}
}
pub fn view(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
div![
C!["flex justify-center my-6"],
div![
C!["flex flex-col w-full p-8 text-gray-800 bg-white shadow-lg pin-r pin-y md:w-4/5 lg:w-4/5"],
products(model, page)
]
]
}
fn products(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
div![
C!["flex-1"],
table![
C!["w-full text-sm lg:text-base"],
attrs!["cellspacing" => 0],
products_head(model),
products_body(model, page),
]
]
}
fn products_head(model: &crate::Model) -> Node<crate::Msg> {
thead![tr![
C!["h-12 uppercase"],
th![C!["hidden md:table-cell"]],
th![C!["text-left"], model.i18n.t("Product")],
th![
C!["lg:text-right text-left pl-5 lg:pl-0"],
span![
C!["lg:hidden"],
attrs![At::Title => model.i18n.t("Quantity")],
model.i18n.t("Qtd")
],
span![C!["hidden lg:inline"], model.i18n.t("Quantity")]
],
th![
C!["hidden text-right md:table-cell"],
model.i18n.t("Unit price")
],
th![C!["text-right"], model.i18n.t("Total price")]
]]
}
fn products_body(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
let items = model
.cart
.items
.values()
.filter_map(|item: &crate::shopping_cart::Item| {
page.products
.products
.get(&item.product_id)
.map(|product| (item, product))
})
.map(
|(item, product): (&crate::shopping_cart::Item, &model::api::Product)| {
item_view(model, item, product)
},
);
tbody![items]
}
fn item_view(
model: &crate::Model,
item: &crate::shopping_cart::Item,
product: &model::api::Product,
) -> Node<crate::Msg> {
use rusty_money::iso::PLN;
use rusty_money::Money;
let img = product
.photos
.first()
.map(|photo| photo.url.as_str())
.unwrap_or_default();
tr![
td![
C!["hidden pb-4 md:table-cell"],
a![
attrs![At::Href => "#"],
img![attrs![
At::Src => img,
At::Class => "w-20 rounded",
At::Alt => "Thumbnail"
]],
]
],
td![a![
attrs![At::Href => "#"],
p![C!["mb-2 md:ml-4"], product.name.as_str()],
form![
attrs![ "action" => "", "method" => "POST"],
button![
attrs!["type" => "submit", "class" => "text-gray-700 md:ml-4"],
small![model.i18n.t("(Remove item)]")]
]
]
]],
td![
C!["justify-center md:justify-end md:flex mt-6"],
div![
C!["w-20 h-10"],
div![
C!["relative flex flex-row w-full h-8"],
input![attrs![
At::Type => "number",
At::Value => **item.quantity,
At::Class => "w-full font-semibold text-center text-gray-700 bg-gray-200 outline-none focus:outline-none hover:text-black focus:text-black"
]],
]
]
],
td![
C!["hidden text-right md:table-cell"],
span![
C!["text-sm lg:text-base font-medium"],
Money::from_minor(**product.price as i64, PLN).to_string()
]
],
td![
C!["text-right"],
span![
C!["text-sm lg:text-base font-medium"],
Money::from_minor(**(product.price * item.quantity) as i64, PLN).to_string()
]
]
]
}

View File

@ -4,7 +4,7 @@ use seed::*;
use crate::pages::Urls;
#[derive(Debug)]
pub enum Msg {
pub enum LogInMsg {
LoginChanged(String),
PasswordChanged(String),
Submit,
@ -16,7 +16,7 @@ pub struct SignInPage {
pub password: model::Password,
}
pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignInPage {
pub fn init(mut _url: Url, _orders: &mut impl Orders<LogInMsg>) -> SignInPage {
SignInPage {
login: model::Login::new(""),
password: model::Password::new(""),
@ -25,7 +25,7 @@ pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignInPage {
pub fn page_changed(_url: Url, _model: &mut SignInPage) {}
pub fn update(_msg: Msg, _model: &mut SignInPage, _orders: &mut impl Orders<crate::Msg>) {}
pub fn update(_msg: LogInMsg, _model: &mut SignInPage, _orders: &mut impl Orders<crate::Msg>) {}
pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> {
let home = Urls::new(&model.url).home();
@ -66,13 +66,13 @@ pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> {
div![super::layout::view(model, content, None)]
}
fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> {
fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<LogInMsg> {
form![
C!["mt-6"],
ev("submit", |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::Submit
LogInMsg::Submit
}),
div![
label![attrs!["for" => "login"], C!["block text-sm text-indigo-800"], model.i18n.t("Login")],
@ -82,7 +82,7 @@ fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> {
ev(Ev::Change, |ev| {
ev.stop_propagation();
ev.prevent_default();
ev.target().map(|target| seed::to_input(&target).value()).map(Msg::LoginChanged)
ev.target().map(|target| seed::to_input(&target).value()).map(LogInMsg::LoginChanged)
})
]
],
@ -94,7 +94,7 @@ fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> {
ev(Ev::Change, |ev| {
ev.stop_propagation();
ev.prevent_default();
ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordChanged)
ev.target().map(|target| seed::to_input(&target).value()).map(LogInMsg::PasswordChanged)
})
],
a![

View File

@ -7,7 +7,7 @@ use seed::*;
use crate::pages::Urls;
#[derive(Debug)]
pub enum Msg {
pub enum RegisterMsg {
LoginChanged(String),
EmailChanged(String),
PasswordChanged(String),
@ -24,7 +24,7 @@ pub struct SignUpPage {
pub password_confirmation: model::PasswordConfirmation,
}
pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignUpPage {
pub fn init(mut _url: Url, _orders: &mut impl Orders<RegisterMsg>) -> SignUpPage {
SignUpPage {
login: model::Login::new(""),
email: model::Email::invalid_empty(),
@ -35,23 +35,23 @@ pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignUpPage {
pub fn page_changed(_url: Url, _model: &mut SignUpPage) {}
pub fn update(msg: Msg, model: &mut SignUpPage, orders: &mut impl Orders<crate::Msg>) {
pub fn update(msg: RegisterMsg, model: &mut SignUpPage, orders: &mut impl Orders<crate::Msg>) {
match msg {
Msg::LoginChanged(value) => {
RegisterMsg::LoginChanged(value) => {
model.login = Login::new(value);
}
Msg::EmailChanged(value) => {
RegisterMsg::EmailChanged(value) => {
if let Ok(email) = Email::from_str(&value) {
model.email = email;
}
}
Msg::PasswordChanged(value) => {
RegisterMsg::PasswordChanged(value) => {
model.password = Password::new(value);
}
Msg::PasswordConfirmationChanged(value) => {
RegisterMsg::PasswordConfirmationChanged(value) => {
model.password_confirmation = PasswordConfirmation::new(value);
}
Msg::Submit => {
RegisterMsg::Submit => {
let email = model.email.clone();
let login = model.login.clone();
let password = model.password.clone();
@ -59,7 +59,7 @@ pub fn update(msg: Msg, model: &mut SignUpPage, orders: &mut impl Orders<crate::
orders.perform_cmd(async move {
crate::Msg::Public(
Msg::AccountCreated(
RegisterMsg::AccountCreated(
crate::api::public::sign_up(model::api::CreateAccountInput {
email,
login,
@ -72,7 +72,7 @@ pub fn update(msg: Msg, model: &mut SignUpPage, orders: &mut impl Orders<crate::
)
});
}
Msg::AccountCreated(_) => {}
RegisterMsg::AccountCreated(_) => {}
}
}
@ -114,13 +114,13 @@ pub fn view(model: &crate::Model, page: &SignUpPage) -> Node<crate::Msg> {
div![super::layout::view(model, content, None)]
}
fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<RegisterMsg> {
form![
C!["mt-6"],
ev("submit", |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::Submit
RegisterMsg::Submit
}),
div![
label![attrs!["for" => "email"], C!["block text-sm text-indigo-800"], model.i18n.t("E-Mail")],
@ -129,7 +129,7 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
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.target().map(|target| seed::to_input(&target).value()).map(Msg::EmailChanged)
ev.target().map(|target| seed::to_input(&target).value()).map(RegisterMsg::EmailChanged)
})
]
],
@ -140,7 +140,7 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
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.target().map(|target| seed::to_input(&target).value()).map(Msg::LoginChanged)
ev.target().map(|target| seed::to_input(&target).value()).map(RegisterMsg::LoginChanged)
})
]
],
@ -151,7 +151,7 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
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.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordChanged)
ev.target().map(|target| seed::to_input(&target).value()).map(RegisterMsg::PasswordChanged)
})
],
div![
@ -166,7 +166,7 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
],
ev(Ev::Change, |ev| {
ev.stop_propagation();
ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordConfirmationChanged)
ev.target().map(|target| seed::to_input(&target).value()).map(RegisterMsg::PasswordConfirmationChanged)
})
],
div![

View File

@ -4,7 +4,7 @@ use seed::prelude::*;
use crate::pages::AdminPage;
use crate::shared::notification::NotificationMsg;
use crate::{pages, Model, Msg, Page, PublicPage};
use crate::{Model, Msg, Page, PublicPage};
#[derive(Debug)]
pub enum SessionMsg {
@ -60,7 +60,7 @@ pub fn redirect_on_session(model: &Model, orders: &mut impl Orders<Msg>) {
PublicPage::Product(_) => {}
PublicPage::SignIn(_) => {}
PublicPage::SignUp(_) => {}
PublicPage::ShoppingCart => {}
PublicPage::ShoppingCart(_) => {}
PublicPage::Checkout => {}
},
}

View File

@ -1,6 +1,8 @@
use seed::app::Orders;
use crate::api::NetRes;
use crate::session::redirect_on_session;
use crate::shared::notification::NotificationMsg;
pub mod notification;
pub mod view;
@ -8,10 +10,16 @@ pub mod view;
#[derive(Debug)]
pub enum SharedMsg {
LoadMe,
MeLoaded(crate::api::NetRes<model::Account>),
MeLoaded(NetRes<model::Account>),
SignIn(model::api::SignInInput),
SignedIn(crate::api::NetRes<model::api::SessionOutput>),
Notification(notification::NotificationMsg),
SignedIn(NetRes<model::api::SessionOutput>),
Notification(NotificationMsg),
}
impl From<SharedMsg> for crate::Msg {
fn from(msg: SharedMsg) -> Self {
Self::Shared(msg)
}
}
#[derive(Debug, Default)]
@ -38,22 +46,22 @@ pub fn update(msg: SharedMsg, model: &mut crate::Model, orders: &mut impl Orders
});
}
}
SharedMsg::MeLoaded(crate::api::NetRes::Success(account)) => {
SharedMsg::MeLoaded(NetRes::Success(account)) => {
model.shared.me = Some(account);
redirect_on_session(model, orders);
}
SharedMsg::MeLoaded(crate::api::NetRes::Error(_error)) => {}
SharedMsg::MeLoaded(crate::api::NetRes::Http(_error)) => {}
SharedMsg::MeLoaded(NetRes::Error(_error)) => {}
SharedMsg::MeLoaded(NetRes::Http(_error)) => {}
SharedMsg::SignIn(input) => {
orders.skip().perform_cmd(async {
SharedMsg::SignedIn(crate::api::public::sign_in(input).await)
});
}
SharedMsg::SignedIn(crate::api::NetRes::Success(pair)) => {
SharedMsg::SignedIn(NetRes::Success(pair)) => {
handle_auth_pair(pair, &mut model.shared, orders);
}
SharedMsg::SignedIn(crate::api::NetRes::Error(_err)) => {}
SharedMsg::SignedIn(crate::api::NetRes::Http(_err)) => {}
SharedMsg::SignedIn(NetRes::Error(_err)) => {}
SharedMsg::SignedIn(NetRes::Http(_err)) => {}
SharedMsg::Notification(msg) => {
notification::update(msg, &mut model.shared, orders);
}

View File

@ -1,21 +1,50 @@
pub mod public_navbar {
use seed::prelude::*;
use seed::*;
use crate::pages::Urls;
use crate::shopping_cart::CartMsg;
use crate::Msg;
pub fn public_navbar(model: &crate::Model) -> Node<Msg> {
pub fn view(model: &crate::Model, products: &crate::model::Products) -> Node<Msg> {
header![
C!["container flex justify-around py-8 mx-auto bg-white"],
div![C!["flex items-center"], logo(model)],
div![
C!["items-center hidden space-x-8 lg:flex"],
navbar_item(div![C![""], "Home"], Urls::new(model.url.clone()).home()),
navbar_item(div![C![""], "Home"], Urls::new(&model.url).home()),
],
div![
C!["flex items-center space-x-2"],
navbar_item(account(), Urls::new(model.url.clone()).sign_in()),
navbar_item(bag(), Urls::new(model.url.clone()).shopping_cart())
navbar_item(
div![
attrs![At::Title => model.i18n.t("Account")],
account()
],
model.shared.me.as_ref()
.map(|_| Urls::new(&model.url).profile())
.unwrap_or_else(|| Urls::new(&model.url).sign_in())
),
navbar_item(
div![
C!["relative"],
attrs![At::Title => model.i18n.t("Shopping cart")],
bag(),
span![
C!["absolute bottom-0 right-0 w-4 h-4 bg-gray-500 text-xs text-white text-center rounded-full ring ring-white"],
model
.cart
.items
.values()
.map(|item| **item.quantity)
.sum::<i32>()
],
super::cart_dropdown::view(model, products),
ev(Ev::MouseEnter, |_ev| Msg::from(CartMsg::Hover)),
ev(Ev::MouseLeave, |_ev| Msg::from(CartMsg::Leave)),
],
Urls::new(model.url.clone()).shopping_cart()
)
]
]
}
@ -30,7 +59,7 @@ fn navbar_item(name: Node<Msg>, path: Url) -> Node<Msg> {
fn logo(model: &crate::Model) -> Node<Msg> {
a![
attrs!["href" => "/"],
attrs![At::Href => Urls::new(&model.url).home()],
match model.logo.as_deref() {
Some(url) => img![
C!["text-2xl font-extrabold text-blue-600"],
@ -46,16 +75,16 @@ fn bag() -> Node<Msg> {
attrs![
"width" => "32px",
"height" => "32px",
"viewBox" => "0 0 32 32",
At::ViewBox => "0 0 32 32",
"xmlns" => "http://www.w3.org/2000/svg",
"class"=>"w-6 h-6",
"fill" => "none",
"stroke" => "currentColor",
"stroke-linecap" => "round",
"stroke-linejoin" => "round",
"stroke-width" => "2"
At::Class => "w-6 h-6",
At::Fill => "none",
At::Stroke => "currentColor",
At::StrokeLinecap => "round",
At::StrokeLineJoin => "round",
At::StrokeWidth => "2"
],
path![attrs!["d" => "M5 9 L5 29 27 29 27 9 Z M10 9 C10 9 10 3 16 3 22 3 22 9 22 9"]]
path![attrs![At::D => "M5 9 L5 29 27 29 27 9 Z M10 9 C10 9 10 3 16 3 22 3 22 9 22 9"]]
]
}
@ -63,16 +92,142 @@ fn account() -> Node<Msg> {
svg![
attrs![
"xmlns" => "http://www.w3.org/2000/svg",
"class" => "w-6 h-6",
"fill" => "none",
"viewBox" => "0 0 24 24",
"stroke" => "currentColor",
At::Class => "w-6 h-6",
At::Fill => "none",
At::ViewBox => "0 0 24 24",
At::Stroke => "currentColor",
],
path![attrs![
"stroke-linecap" => "round",
"stroke-linejoin" => "round",
"stroke-width" => "2",
"d" => "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
At::StrokeLinecap => "round",
At::StrokeLineJoin => "round",
At::StrokeWidth => "2",
At::D => "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
]],
]
}
}
pub mod cart_dropdown {
use model::ProductId;
use seed::prelude::*;
use seed::*;
use crate::shopping_cart::Item;
use crate::{Model, Msg};
macro_rules! filter_products {
($model: expr, $products: expr) => {
$model
.cart
.items
.values()
.filter_map(|item: &Item| filter_product(item, $products, item.product_id))
};
}
pub fn view(model: &Model, products: &crate::model::Products) -> Node<Msg> {
let items = filter_products!(model, products).map(|(it, product)| item(model, it, product));
div![
C![
"absolute w-full rounded-b border-t-0 z-10",
if model.cart.hover {
"visible"
} else {
"invisible"
}
],
div![C!["shadow-xl w-64"], items, checkout(model, products)]
]
}
fn item(model: &Model, item: &Item, product: &model::api::Product) -> Node<Msg> {
let img = product
.photos
.first()
.map(|photo| photo.url.as_str())
.unwrap_or_default();
let price = rusty_money::Money::from_minor(
**(product.price * item.quantity) as i64,
rusty_money::iso::PLN,
)
.to_string();
div![
C!["p-2 flex bg-white hover:bg-gray-100 cursor-pointer border-b border-gray-100"],
div![C!["p-2 w-12"], img![attrs![At::Src => img]]],
div![
C!["flex-auto text-sm w-32"],
div![C!["font-bold"], product.name.as_str()],
div![C!["truncate"], product.short_description.as_str()],
div![
C!["text-gray-400"],
model.i18n.t("Qty:"),
" ",
**item.quantity
],
],
div![
C!["flex flex-col w-18 font-medium items-end"],
div![
C!["w-4 h-4 mb-6 hover:bg-red-200 rounded-full cursor-pointer text-red-700"],
trash_icon(),
],
price
]
]
}
fn trash_icon() -> Node<Msg> {
svg![
attrs![
"xmlns" => Namespace::Svg.as_str(),
"width" => "100%",
"height" => "100%",
"fill" => "none",
"viewBox" => "0 0 24 24",
"stroke" => "currentColor",
"stroke-width" => "2",
"stroke-linecap" => "round",
"stroke-linejoin" => "round",
"class" => "feather feather-trash-2"
],
polyline![attrs!["points" => "3 6 5 6 21 6"]],
path![
attrs!["d"=>"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"]
],
line_![attrs!["x1"=>"10", "y1" => "11", "x2" => "10", "y2" => "17"]],
line_![attrs!["x1"=>"14", "y1" => "11", "x2" => "14", "y2" => "17"]]
]
}
fn checkout(model: &Model, products: &crate::model::Products) -> Node<Msg> {
let sum: i32 = filter_products!(model, products)
.map(|(item, product): (&Item, &model::api::Product)| **item.quantity * **product.price)
.sum();
let sum = rusty_money::Money::from_minor(sum as i64, rusty_money::iso::PLN);
div![
C!["p-4 justify-center flex"],
button![
C![
"text-base undefined hover:scale-110 focus:outline-none flex justify-center px-4 py-2 rounded font-bold cursor-pointer"
"hover:bg-teal-700 hover:text-teal-100 bg-white text-teal-700 border duration-200 ease-in-out border-teal-600 transition"
],
model.i18n.t("Checkout"),
" ",
format!("{}", sum)
]
]
}
fn filter_product<'item, 'product>(
item: &'item Item,
products: &'product crate::model::Products,
product_id: ProductId,
) -> Option<(&'item Item, &'product model::api::Product)> {
products
.products
.get(&product_id)
.map(|product| (item, product))
}
}

View File

@ -16,6 +16,8 @@ pub enum CartMsg {
quantity_unit: QuantityUnit,
product_id: ProductId,
},
Hover,
Leave,
}
impl From<CartMsg> for Msg {
@ -28,15 +30,17 @@ pub type Items = indexmap::IndexMap<ProductId, Item>;
#[derive(Debug, Serialize, Deserialize)]
pub struct Item {
product_id: ProductId,
quantity: Quantity,
quantity_unit: QuantityUnit,
pub product_id: ProductId,
pub quantity: Quantity,
pub quantity_unit: QuantityUnit,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ShoppingCart {
pub cart_id: Option<model::ShoppingCartId>,
pub items: Items,
#[serde(skip)]
pub hover: bool,
}
pub fn init(model: &mut Model, _orders: &mut impl Orders<Msg>) {
@ -52,22 +56,34 @@ pub fn update(msg: CartMsg, model: &mut Model, _orders: &mut impl Orders<Msg>) {
} => {
{
let items: &mut Items = &mut model.cart.items;
let entry = items.entry(product_id).or_insert_with(|| Item {
let entry: &mut Item = items.entry(product_id).or_insert_with(|| Item {
quantity,
quantity_unit,
product_id,
});
entry.quantity = quantity;
entry.quantity = entry.quantity + quantity;
entry.quantity_unit = quantity_unit;
}
store_local(&model.cart);
}
CartMsg::ModifyItem { .. } => {}
CartMsg::Hover => {
model.cart.hover = true;
}
CartMsg::Leave => {
model.cart.hover = false;
}
}
}
fn load_local() -> ShoppingCart {
LocalStorage::get("ct").unwrap_or_default()
match LocalStorage::get("ct") {
Ok(cart) => cart,
Err(e) => {
seed::error!(e);
ShoppingCart::default()
}
}
}
fn store_local(cart: &ShoppingCart) {
LocalStorage::insert("ct", cart).ok();