Shopping cart

This commit is contained in:
Adrian Woźniak 2022-05-17 16:04:29 +02:00
parent f0d01a9735
commit c9eb49410b
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
15 changed files with 404 additions and 27 deletions

View File

@ -1,8 +1,10 @@
pub mod api_v1;
use actix_web::web::{Path, ServiceConfig};
use actix_web::web::{Data, Json, Path, ServiceConfig};
use actix_web::{get, HttpResponse};
pub use api_v1::{Error as V1Error, ShoppingCartError as V1ShoppingCartError};
use config::SharedAppConfig;
use model::api::Config;
#[macro_export]
macro_rules! public_send_db {
@ -57,6 +59,26 @@ async fn landing() -> HttpResponse {
HttpResponse::NotImplemented().body("")
}
#[get("/config.json")]
async fn client_config(config: Data<SharedAppConfig>) -> Json<model::api::Config> {
let (optional_payment, currency) = {
let lock = config.lock();
let p = lock.payment();
(p.optional_payment(), p.currency())
};
Json(Config {
coupons: false,
pay_methods: match optional_payment {
true => vec![
model::PaymentMethod::PayU,
model::PaymentMethod::PaymentOnTheSpot,
],
false => vec![model::PaymentMethod::PayU],
},
currency,
})
}
#[get("/pay-on-site")]
async fn pay_on_site() -> HttpResponse {
HttpResponse::Ok().body("<h1>Pay on Site</h1>")
@ -93,5 +115,6 @@ pub fn configure(config: &mut ServiceConfig) {
config
.service(landing)
.service(svg)
.service(client_config)
.configure(api_v1::configure);
}

View File

@ -11,6 +11,13 @@ pub struct Failure {
pub errors: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
pub pay_methods: Vec<PaymentMethod>,
pub coupons: bool,
pub currency: String,
}
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)]
#[serde(transparent)]

View File

@ -1,8 +1,11 @@
use seed::fetch::{Header, Method, Request};
use seed::fetch::{Method, Request};
use crate::api::{perform, NetRes};
pub async fn sign_in(identity: String, password: model::Password) -> NetRes<model::Account> {
pub async fn sign_in(
identity: String,
password: model::Password,
) -> NetRes<model::api::SessionOutput> {
use model::api::admin::SignInInput;
let input = if identity.contains('@') {

View File

@ -3,6 +3,10 @@ use seed::fetch::{Header, Method, Request};
use crate::api::perform;
pub async fn config() -> super::NetRes<model::api::Config> {
perform(Request::new("/config.json").method(Method::Get)).await
}
pub async fn fetch_products() -> super::NetRes<model::api::Products> {
perform(Request::new("/api/v1/products").method(Method::Get)).await
}

View File

@ -47,5 +47,22 @@ pub fn define(i18n: &mut I18n) {
.define("Quantity", "Ilość")
.define("Unit price", "Cena jednostkowa")
.define("Total price", "Cena łączna")
.define("Delivery", "Sposób dostawy");
.define("Delivery", "Sposób dostawy")
.define("Coupon Code", "Kod rabatowy")
.define(
"If you have a coupon code, please enter it in the box below",
"Jeśli posiadasz kod kuponu, wpisz go w poniższe pole.",
)
.define("Apply coupon", "Zastosuj kupon")
.define("Instruction for seller", "Instrukcje dla sprzedawcy")
.define(
"If you have some information for the seller you can leave them in the box below",
"Jeśli masz jakieś informacje dla sprzedawcy, możesz je zostawić w poniższym polu",
)
.define("Order Details", "Szczegóły zamówienia")
.define("Shipping and additional costs are calculated based on values you have entered", "Koszty wysyłki i koszty dodatkowe są obliczane na podstawie wprowadzonych przez użytkownika wartości")
.define("Subtotal", "Suma częściowa")
.define("New Subtotal", "Łącznie")
.define("Total", "Łącznie")
.define("Proceed to checkout", "Przejdź do kasy");
}

View File

@ -13,6 +13,7 @@ pub mod shopping_cart;
use seed::empty;
use seed::prelude::*;
use crate::api::NetRes;
use crate::i18n::I18n;
use crate::model::Model;
use crate::pages::{AdminPage, Msg, Page, PublicPage};
@ -105,7 +106,9 @@ macro_rules! fetch_page {
}
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
orders.subscribe(Msg::UrlChanged);
orders
.subscribe(Msg::UrlChanged)
.perform_cmd(async { Msg::Config(crate::api::public::config().await) });
let mut model = Model {
url: url.clone().set_path(&[] as &[&str]),
@ -120,6 +123,7 @@ fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
i18n: I18n::load(),
cart: Default::default(),
config: model::Config::default(),
#[cfg(debug_assertions)]
debug_modal: false,
};
@ -144,6 +148,11 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::NoOp => {
orders.skip();
}
Msg::Config(res) => {
if let NetRes::Success(config) = res {
model.config = config.into();
}
}
Msg::Shared(msg) => {
shared::update(msg, model, orders);
}

View File

@ -13,11 +13,45 @@ pub struct Model {
pub shared: crate::shared::Model,
pub i18n: I18n,
pub cart: shopping_cart::ShoppingCart,
pub config: Config,
#[cfg(debug_assertions)]
pub debug_modal: bool,
}
#[derive(Debug)]
pub struct Config {
pub pay_methods: Vec<::model::PaymentMethod>,
pub coupons: bool,
pub currency: &'static rusty_money::iso::Currency,
}
impl Default for Config {
fn default() -> Self {
Self {
pay_methods: vec![],
coupons: false,
currency: rusty_money::iso::PLN,
}
}
}
impl From<::model::api::Config> for Config {
fn from(
model::api::Config {
pay_methods,
coupons,
currency,
}: model::api::Config,
) -> Self {
Self {
pay_methods,
coupons,
currency: rusty_money::iso::find(&currency).unwrap_or(rusty_money::iso::PLN),
}
}
}
#[derive(Debug, Default)]
pub struct Products {
pub categories: Vec<model::api::Category>,

View File

@ -4,12 +4,13 @@ pub mod public;
use seed::app::{subs, Orders};
use seed::{struct_urls, Url};
use crate::shared;
use crate::{shared, NetRes};
#[derive(Debug)]
pub enum Msg {
#[cfg(debug_assertions)]
NoOp,
Config(NetRes<::model::api::Config>),
Public(public::Msg),
Admin(admin::Msg),
UrlChanged(subs::UrlChanged),

View File

@ -1,3 +1,4 @@
pub mod checkout;
pub mod listing;
pub mod product;
pub mod shopping_cart;

View File

View File

@ -73,7 +73,8 @@ pub fn update(msg: ListingMsg, model: &mut ListingPage, orders: &mut impl Orders
orders.skip().perform_cmd({
async {
crate::Msg::Public(
ListingMsg::ProductsFetched(crate::api::public::fetch_products().await).into(),
ListingMsg::ProductsFetched(crate::api::public::fetch_products().await)
.into(),
)
}
});
@ -85,7 +86,8 @@ pub fn update(msg: ListingMsg, model: &mut ListingPage, orders: &mut impl Orders
.filter_product_ids(|product| filter_product(model, product));
model.visible_products = ids;
}
ListingMsg::ProductsFetched(NetRes::Error(_)) | ListingMsg::ProductsFetched(NetRes::Http(_)) => {
ListingMsg::ProductsFetched(NetRes::Error(_))
| ListingMsg::ProductsFetched(NetRes::Http(_)) => {
model.errors.push("Failed to load products".into());
}
}
@ -120,9 +122,9 @@ pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> {
}
fn product(model: &crate::Model, product: &model::api::Product) -> Node<crate::Msg> {
use rusty_money::{iso, Money};
use rusty_money::Money;
let price = Money::from_minor(**product.price as i64, iso::PLN).to_string();
let price = Money::from_minor(**product.price as i64, model.config.currency).to_string();
let _description = product.short_description.as_str();
let name = product.name.as_str();
let img = product

View File

@ -52,8 +52,13 @@ pub fn view(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
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)
]
div![
C!["flex-1"],
products(model, page),
div![C!["pb-6 mt-6"]],
summary(model, page),
]
]
];
div![
@ -62,18 +67,284 @@ pub fn view(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
]
}
fn products(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
fn summary(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),
C!["my-4 mt-6 -mx-2 lg:flex"],
summary_left(model, page),
summary_right::view(model, page)
]
}
fn summary_left(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
div![
C!["lg:px-2 lg:w-1/2"],
IF![model.config.coupons => div![C!["p-4 bg-gray-100 rounded-full"], h1![C!["ml-2 font-bold uppercase"], model.i18n.t("Coupon Code")]]],
IF![model.config.coupons => coupon_form(model, page)],
div![
C!["p-4 bg-gray-100 rounded-full", IF![model.config.coupons => "mt-6"]],
div![
C!["ml-2 font-bold uppercase"],
model.i18n.t("Instruction for seller")
]
],
div![C!["p-4"], p![C!["mb-4 italic"], model.i18n.t("If you have some information for the seller you can leave them in the box below")]],
textarea![C!["w-full h-24 p-2 bg-gray-100 rounded border-none"]]
]
}
fn coupon_form(model: &crate::Model, _page: &ShoppingCartPage) -> Node<crate::Msg> {
div![
C!["p-4"],
p![
C!["mb-4 italic"],
model
.i18n
.t("If you have a coupon code, please enter it in the box below")
],
div![
C!["justify-center md:flex"],
form![
div![
C!["flex items-center w-full h-13 pl-3 bg-white bg-gray-100 border rounded-full"],
input![C!["w-full bg-gray-100 outline-none appearance-none focus:outline-none active:outline-none"]],
button![
C!["text-sm flex items-center px-3 py-1 text-white bg-gray-800 rounded-full outline-none md:px-4 hover:bg-gray-700 focus:outline-none active:outline-none"],
gift_icon(),
span![C!["font-medium"], model.i18n.t("Apply coupon")]
]
]
]
]
]
}
fn gift_icon() -> Node<crate::Msg> {
svg![
attrs![
"aria-hidden" => "true",
"data-prefix" => "fas",
"data-icon" => "gift",
"class" => "w-8",
"xmlns" => "http://www.w3.org/2000/svg",
"viewBox" => "0 0 512 512"
],
path![attrs![
"fill" => "currentColor",
"d" => "M32 448c0 17.7 14.3 32 32 32h160V320H32v128zm256 32h160c17.7 0 32-14.3 32-32V320H288v160zm192-320h-42.1c6.2-12.1 10.1-25.5 10.1-40 0-48.5-39.5-88-88-88-41.6 0-68.5 21.3-103 68.3-34.5-47-61.4-68.3-103-68.3-48.5 0-88 39.5-88 88 0 14.5 3.8 27.9 10.1 40H32c-17.7 0-32 14.3-32 32v80c0 8.8 7.2 16 16 16h480c8.8 0 16-7.2 16-16v-80c0-17.7-14.3-32-32-32zm-326.1 0c-22.1 0-40-17.9-40-40s17.9-40 40-40c19.9 0 34.6 3.3 86.1 80h-86.1zm206.1 0h-86.1c51.4-76.5 65.7-80 86.1-80 22.1 0 40 17.9 40 40s-17.9 40-40 40z"
]]
]
}
mod summary_right {
use rusty_money::Money;
use seed::prelude::*;
use seed::*;
use crate::pages::public::shopping_cart::ShoppingCartPage;
use crate::pages::Urls;
pub fn view(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
let subtotal_value = 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)| {
**(product.price * item.quantity)
},
)
.sum::<i32>() as i64;
div![
C!["lg:px-2 lg:w-1/2"],
div![
C!["p-4 bg-gray-100 rounded-full"],
h1![
C!["ml-2 font-bold uppercase"],
model.i18n.t("Order Details")
]
],
div![
C!["p-4"],
p![C!["mb-6 italic"], model.i18n.t("Shipping and additional costs are calculated based on values you have entered")],
subtotal(model, page, subtotal_value),
coupon_subtotal(model, page),
new_subtotal(model, page, subtotal_value),
total(model, page, subtotal_value),
checkout(model),
]
]
}
fn checkout(model: &crate::Model) -> Node<crate::Msg> {
a![
attrs![At::Href => Urls::new(&model.url).checkout()],
button![
C!["flex justify-center w-full px-10 py-3 mt-6 font-medium text-white uppercase bg-gray-800 rounded-full shadow item-center hover:bg-gray-700 focus:shadow-outline focus:outline-none"],
cart_icon(),
span![
C!["ml-2 mt-5px"],
model.i18n.t("Proceed to checkout")
],
ev(Ev::Click, move |ev| {
ev.prevent_default()
})
]
]
}
fn subtotal(
model: &crate::Model,
_page: &ShoppingCartPage,
subtotal_value: i64,
) -> Node<crate::Msg> {
let subtotal = Money::from_minor(subtotal_value, model.config.currency);
div![
C!["flex justify-between border-b"],
div![
C!["lg:px-4 lg:py-2 m-2 text-lg lg:text-xl font-bold text-center text-gray-800"],
model.i18n.t("Subtotal")
],
div![
C!["lg:px-4 lg:py-2 m-2 lg:text-lg font-bold text-center text-gray-900"],
subtotal.to_string()
]
]
}
fn coupon_subtotal(model: &crate::Model, _page: &ShoppingCartPage) -> Node<crate::Msg> {
if !model.config.coupons {
return empty![];
}
// TODO! Coupons
let coupon = "90off";
let coupon_discount = 1654;
div![
C!["flex justify-between pt-4 border-b"],
div![
C!["flex lg:px-4 lg:py-2 m-2 text-lg lg:text-xl font-bold text-gray-800"],
form![button![C!["mr-2 mt-1 lg:mt-2"], trash_icon()]],
model.i18n.t("Coupon"),
" ",
format!("{:?}", coupon)
],
div![
C!["lg:px-4 lg:py-2 m-2 lg:text-lg font-bold text-center text-green-700"],
format!(
"-{}",
Money::from_minor(coupon_discount, model.config.currency)
)
]
]
}
fn new_subtotal(
model: &crate::Model,
_page: &ShoppingCartPage,
subtotal_value: i64,
) -> Node<crate::Msg> {
// TODO! Coupons and Delivery
let coupon_discount = 0;
if !model.config.coupons {
return empty![];
}
let new_subtotal =
Money::from_minor(subtotal_value - coupon_discount, model.config.currency);
div![
C!["flex justify-between pt-4 border-b"],
div![
C!["lg:px-4 lg:py-2 m-2 text-lg lg:text-xl font-bold text-center text-gray-800"],
model.i18n.t("New Subtotal")
],
div![
C!["lg:px-4 lg:py-2 m-2 lg:text-lg font-bold text-center text-gray-900"],
new_subtotal.to_string()
]
]
}
fn total(
model: &crate::Model,
_page: &ShoppingCartPage,
subtotal_value: i64,
) -> Node<crate::Msg> {
// TODO! Coupons and Delivery
let coupon_discount = 0;
let delivery_cost = 0;
let new_subtotal = Money::from_minor(
subtotal_value - coupon_discount + delivery_cost,
model.config.currency,
);
div![
C!["flex justify-between pt-4 border-b"],
div![
C!["lg:px-4 lg:py-2 m-2 text-lg lg:text-xl font-bold text-center text-gray-800"],
model.i18n.t("Total")
],
div![
C!["lg:px-4 lg:py-2 m-2 lg:text-lg font-bold text-center text-gray-900"],
new_subtotal.to_string()
]
]
}
fn trash_icon() -> Node<crate::Msg> {
svg![
attrs![
"aria-hidden" => "true",
"data-prefix" => "far",
"data-icon" => "trash-alt",
"class" => "w-4 text-red-600 hover:text-red-800",
"xmlns" => "http://www.w3.org/2000/svg",
"viewBox" => "0 0 448 512"
],
path![attrs![
"fill" => "currentColor",
"d" => "M268 416h24a12 12 0 0012-12V188a12 12 0 00-12-12h-24a12 12 0 00-12 12v216a12 12 0 0012 12zM432 80h-82.41l-34-56.7A48 48 0 00274.41 0H173.59a48 48 0 00-41.16 23.3L98.41 80H16A16 16 0 000 96v16a16 16 0 0016 16h16v336a48 48 0 0048 48h288a48 48 0 0048-48V128h16a16 16 0 0016-16V96a16 16 0 00-16-16zM171.84 50.91A6 6 0 01177 48h94a6 6 0 015.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0012-12V188a12 12 0 00-12-12h-24a12 12 0 00-12 12v216a12 12 0 0012 12z"
]]
]
}
fn cart_icon() -> Node<crate::Msg> {
svg![
attrs![
"aria-hidden" => "true",
"data-prefix" => "far",
"data-icon" => "credit-card",
"class" => "w-8",
"xmlns" => "http://www.w3.org/2000/svg",
"viewBox" => "0 0 576 512"
],
path![attrs![
"fill" => "currentColor",
"d" => "M527.9 32H48.1C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48.1 48h479.8c26.6 0 48.1-21.5 48.1-48V80c0-26.5-21.5-48-48.1-48zM54.1 80h467.8c3.3 0 6 2.7 6 6v42H48.1V86c0-3.3 2.7-6 6-6zm467.8 352H54.1c-3.3 0-6-2.7-6-6V256h479.8v170c0 3.3-2.7 6-6 6zM192 332v40c0 6.6-5.4 12-12 12h-72c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h72c6.6 0 12 5.4 12 12zm192 0v40c0 6.6-5.4 12-12 12H236c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h136c6.6 0 12 5.4 12 12z"
]]
]
}
}
fn products(model: &crate::Model, page: &ShoppingCartPage) -> Node<crate::Msg> {
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"],
@ -120,7 +391,6 @@ fn item_view(
item: &crate::shopping_cart::Item,
product: &model::api::Product,
) -> Node<crate::Msg> {
use rusty_money::iso::PLN;
use rusty_money::Money;
let img = product
@ -139,7 +409,7 @@ fn item_view(
td![
C!["hidden pb-4 md:table-cell"],
a![
attrs![At::Href => product_url.clone()],
attrs![At::Href => &product_url],
img![attrs![
At::Src => img,
At::Class => "w-20 rounded",
@ -148,7 +418,7 @@ fn item_view(
]
],
td![a![
attrs![At::Href => product_url.clone()],
attrs![At::Href => &product_url],
p![C!["mb-2 md:ml-4"], product.name.as_str()],
form![
ev(Ev::Submit, move |ev| {
@ -177,7 +447,7 @@ fn item_view(
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"
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 border-none"
],
ev(Ev::Change, move |ev| {
ev.stop_propagation();
@ -200,14 +470,18 @@ fn item_view(
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()
Money::from_minor(**product.price as i64, model.config.currency).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()
Money::from_minor(
**(product.price * item.quantity) as i64,
model.config.currency
)
.to_string()
]
]
]

View File

@ -120,6 +120,7 @@ pub fn update(msg: SessionMsg, model: &mut Model, orders: &mut impl Orders<Msg>)
access_token,
refresh_token,
exp,
role: _,
})) => {
orders
.skip()

View File

@ -77,6 +77,7 @@ fn handle_auth_pair(
access_token,
refresh_token,
exp,
role: _,
} = pair;
model.access_token = Some(access_token);

View File

@ -149,7 +149,7 @@ pub mod cart_dropdown {
let price = rusty_money::Money::from_minor(
**(product.price * item.quantity) as i64,
rusty_money::iso::PLN,
model.config.currency,
)
.to_string();
div![
@ -204,7 +204,7 @@ pub mod cart_dropdown {
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);
let sum = rusty_money::Money::from_minor(sum as i64, model.config.currency);
div![
C!["p-4 justify-center flex"],