Add price range

This commit is contained in:
Adrian Woźniak 2022-07-20 16:03:23 +02:00
parent 12f95612d6
commit de9f1742d1
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
11 changed files with 256 additions and 10 deletions

2
Cargo.lock generated
View File

@ -1308,6 +1308,7 @@ dependencies = [
"argon2", "argon2",
"askama", "askama",
"base64", "base64",
"byteorder",
"chrono", "chrono",
"futures", "futures",
"futures-util", "futures-util",
@ -1317,6 +1318,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"sqlx-core",
"tracing", "tracing",
"tracing-actix-web", "tracing-actix-web",
"tracing-subscriber", "tracing-subscriber",

View File

@ -15,6 +15,7 @@ actix-utils = { version = "3.0.0" }
actix-web = { version = "*" } actix-web = { version = "*" }
argon2 = { version = "0.4.1" } argon2 = { version = "0.4.1" }
askama = { version = "*", features = ["serde-json"] } askama = { version = "*", features = ["serde-json"] }
byteorder = { version = "1.4.3" }
chrono = { version = "*", features = ["serde"] } chrono = { version = "*", features = ["serde"] }
futures = { version = "0.3.21", features = ["async-await", "std"] } futures = { version = "0.3.21", features = ["async-await", "std"] }
futures-util = { version = "0.3.21", features = [] } futures-util = { version = "0.3.21", features = [] }
@ -24,6 +25,7 @@ rand = { version = "0.8.5", features = [] }
serde = { version = "*", features = ["derive"] } serde = { version = "*", features = ["derive"] }
serde_json = { version = "*" } serde_json = { version = "*" }
sqlx = { version = "*", features = ["runtime-actix-rustls", "postgres", "uuid", "chrono"] } sqlx = { version = "*", features = ["runtime-actix-rustls", "postgres", "uuid", "chrono"] }
sqlx-core = { version = "0.6.0" }
tracing = { version = "*" } tracing = { version = "*" }
tracing-actix-web = { version = "*" } tracing-actix-web = { version = "*" }
tracing-subscriber = { version = "*" } tracing-subscriber = { version = "*" }

View File

@ -1,7 +1,7 @@
{% extends "../base.html" %} {% extends "../base.html" %}
{% block content %} {% block content %}
<marketplace-offers> <marketplace-offers>
<h1>Sprzedaż niepotrzebych rzeczy</h1> <h1>Sprzedaż niepotrzebnych rzeczy</h1>
<offer-form></offer-form> <offer-form></offer-form>
{% for offer in offers %} {% for offer in offers %}
@ -9,6 +9,15 @@
offer-id="{{offer.id}}" offer-id="{{offer.id}}"
description="{{offer.description}}" description="{{offer.description}}"
picture-url="{{offer.picture_url}}" picture-url="{{offer.picture_url}}"
{% match offer.price_range %}
{% when PriceRange::Free %}
price-range="free"
{% when PriceRange::Fixed with { value } %}
price-range="{{value}}"
{% when PriceRange::Range with { min, max } %}
price-range-min="{{min}}"
price-range-max="{{max}}"
{% endmatch %}
></marketplace-offer> ></marketplace-offer>
{% endfor %} {% endfor %}
</marketplace-offers> </marketplace-offers>

View File

@ -1,8 +1,10 @@
import { Component, INPUT_STYLE } from "../shared"; import { Component, INPUT_STYLE, PriceRange } from "../shared";
customElements.define('marketplace-offer', class extends Component { customElements.define('marketplace-offer', class extends Component {
#price_range;
static get observedAttributes() { static get observedAttributes() {
return ['offer-id', 'description', 'picture-url'] return ['offer-id', 'description', 'picture-url', "price-range", "price-range-min", "price-range-max"]
} }
constructor() { constructor() {
@ -36,15 +38,17 @@ customElements.define('marketplace-offer', class extends Component {
min-height: 200px; min-height: 200px;
} }
} }
${INPUT_STYLE} ${ INPUT_STYLE }
</style> </style>
<section> <section>
<div id="preview"> <div id="preview">
<img alt="" src="" id="picture" /> <img alt="" src="" id="picture" />
</div> </div>
<p id="description"></p> <p id="description"></p>
<p id="price"></p>
</section> </section>
`); `);
this.#price_range = new PriceRange(0, 0);
} }
get offer_id() { get offer_id() {
@ -74,4 +78,56 @@ customElements.define('marketplace-offer', class extends Component {
this.setAttribute('picture-url', v); this.setAttribute('picture-url', v);
this.shadowRoot.querySelector('#picture').src = v; this.shadowRoot.querySelector('#picture').src = v;
} }
get price_range() {
return this.#price_range
}
set price_range(v) {
v = v + '';
if (!v.match(/free|(\d+([,.]\d{2})?)(,(\d+([,.]\d{2})?))?/i))
return;
if (v === 'free') return this.#price_range = new PriceRange(v, 0);
if (v.includes(',')) {
const [min, max, ...r] = v.split(',');
this.#price_range = new PriceRange(parseInt(min), parseInt(max));
}
this.#displayPrice();
}
get price_range_min() {
return this.#price_range.min
}
set price_range_min(v) {
this.#price_range.min = v;
this.#displayPrice();
}
get price_range_max() {
return this.#price_range.max
}
set price_range_max(v) {
this.#price_range.max = v;
this.#displayPrice();
}
#displayPrice() {
const view = this.shadowRoot.querySelector('#price');
if (this.#price_range.isFree) {
view.innerHTML = 'Za darmo';
}
if (this.#price_range.isRange) {
view.innerHTML = `
<span>${this.#price_range.min}PLN</span>
<span>${this.#price_range.max}PLN</span>
`;
}
if (this.#price_range.isFixed) {
view.innerHTML = `${this.#price_range.min}PLN`;
}
return '';
}
}); });

View File

@ -20,6 +20,16 @@ customElements.define('offer-form', class extends Component {
#descriptionSection { #descriptionSection {
width: calc(100% - 230px); width: calc(100% - 230px);
} }
@media only screen and (min-device-width: 1200px) {
#priceSection {
width: 300px;
margin-right: 16px;
}
#descriptionSection {
width: calc(100% - 550px);
margin-right: 16px;
}
}
${ FORM_STYLE } ${ FORM_STYLE }
</style> </style>
<section> <section>
@ -32,6 +42,18 @@ customElements.define('offer-form', class extends Component {
<label for="description">Opis</label> <label for="description">Opis</label>
<input name="description" id="description" type="text" /> <input name="description" id="description" type="text" />
</div> </div>
<div id="priceSection">
<div>
<label>Cena minimalna</label>
<price-input id="priceMinUI" value="0"></price-input>
<input name="price_min" id="priceMin" type="hidden" />
</div>
<div>
<label>Cena maksymalna</label>
<price-input id="priceMaxUI" value="0"></price-input>
<input name="price_max" id="priceMax" type="hidden" />
</div>
</div>
<div> <div>
<input id="submit" type="submit" value="Utwórz" /> <input id="submit" type="submit" value="Utwórz" />
</div> </div>
@ -39,6 +61,15 @@ customElements.define('offer-form', class extends Component {
</section> </section>
`); `);
this.shadowRoot.querySelector('#priceMinUI').addEventListener('change', ev => {
ev.stopPropagation();
this.shadowRoot.querySelector('#priceMin').value = ev.target.value;
});
this.shadowRoot.querySelector('#priceMaxUI').addEventListener('change', ev => {
ev.stopPropagation();
this.shadowRoot.querySelector('#priceMax').value = ev.target.value;
});
this.addEventListener('image-input:uploaded', ev => { this.addEventListener('image-input:uploaded', ev => {
this.picture_url = ev.detail; this.picture_url = ev.detail;
}); });

View File

@ -185,6 +185,44 @@ export class PseudoForm extends Component {
} }
} }
export class PriceRange {
#min;
#max;
constructor(min, max) {
this.#min = min || 0;
this.#max = max || 0;
}
get isFree() {
return this.#min === 'free' || (this.#min === 0 && this.#max === 0)
}
get isRange() {
return this.#max > 0;
}
get isFixed() {
return !this.isFree && !this.isRange
}
get min() {
return this.#min
}
set min(v) {
this.#min = v;
}
get max() {
return this.#max
}
set max(v) {
this.#max = v;
}
}
export const fireFbReady = () => { export const fireFbReady = () => {
fbReady = true; fbReady = true;
for (const fn of fbQueue) fn(); for (const fn of fbQueue) fn();

View File

@ -0,0 +1,8 @@
CREATE TYPE "PriceRange" AS
(
min int,
max int
);
ALTER TABLE offers
ADD COLUMN price_range "PriceRange" NOT NULL DEFAULT '(0, 0)';

View File

@ -1,9 +1,14 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use byteorder::ByteOrder;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type}; use sqlx::{FromRow, Type};
use sqlx_core::database::{HasArguments, HasValueRef};
use sqlx_core::encode::IsNull;
use sqlx_core::error::BoxDynError;
use sqlx_core::postgres::{PgValueFormat, Postgres};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Default, PartialOrd, PartialEq, Eq, Copy, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Default, PartialOrd, PartialEq, Eq, Copy, Clone, Serialize, Deserialize, Type)]
@ -165,10 +170,89 @@ pub struct UpdateLocalBusinessInput {
pub description: String, pub description: String,
} }
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum PriceRange {
Free,
Fixed { value: i32 },
Range { min: i32, max: i32 },
}
impl From<(i32, i32)> for PriceRange {
fn from((min, max): (i32, i32)) -> Self {
match (min, max) {
(0, 0) => Self::Free,
(_, 0) => Self::Fixed { value: min },
_ => Self::Range { min, max },
}
}
}
impl sqlx::Type<Postgres> for PriceRange {
fn type_info() -> sqlx::postgres::PgTypeInfo {
sqlx::postgres::PgTypeInfo::with_name("PriceRange")
}
}
fn take_i32(bytes: &mut &[u8]) -> i32 {
let value = byteorder::BigEndian::read_i32(&bytes[0..4]);
*bytes = &bytes[4..];
value
}
impl<'l> sqlx::Decode<'l, Postgres> for PriceRange {
fn decode(value: <Postgres as HasValueRef<'l>>::ValueRef) -> Result<Self, BoxDynError> {
match value.format() {
PgValueFormat::Text => {
let s = value.as_str()?;
eprintln!("{s:?}");
Ok(Self::Free)
}
PgValueFormat::Binary => {
let mut bytes = value.as_bytes()?;
let _len = take_i32(&mut bytes);
let _ty = take_i32(&mut bytes);
let _min_len = take_i32(&mut bytes);
let min = take_i32(&mut bytes);
let _ty = take_i32(&mut bytes);
let _max_len = take_i32(&mut bytes);
let max = take_i32(&mut bytes);
Ok((min, max).into())
}
}
}
}
impl<'l> sqlx::Encode<'l, Postgres> for PriceRange {
fn encode_by_ref(&self, buf: &mut <Postgres as HasArguments<'l>>::ArgumentBuffer) -> IsNull {
match self {
PriceRange::Free => {
let _ = 0.encode(buf);
let _ = 0.encode(buf);
true.encode(buf)
}
PriceRange::Fixed { value } => {
let _ = value.encode(buf);
let _ = 0.encode(buf);
false.encode(buf)
}
PriceRange::Range { min, max } => {
let _ = min.encode(buf);
let _ = max.encode(buf);
false.encode(buf)
}
}
}
}
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Offer { pub struct Offer {
pub id: i32, pub id: i32,
pub owner_id: i32, pub owner_id: i32,
pub price_range: PriceRange,
pub description: String, pub description: String,
pub picture_url: String, pub picture_url: String,
pub state: OfferState, pub state: OfferState,
@ -194,7 +278,7 @@ pub struct UpdateLocalBusinessItemInput {
pub picture_url: String, pub picture_url: String,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Deserialize)]
pub struct DeleteNewsArticleInput { pub struct DeleteNewsArticleInput {
pub id: i32, pub id: i32,
} }
@ -229,6 +313,7 @@ pub struct CreateOfferInput {
pub picture_url: String, pub picture_url: String,
pub state: OfferState, pub state: OfferState,
pub owner_id: i32, pub owner_id: i32,
pub price_range: PriceRange,
} }
#[derive(Debug)] #[derive(Debug)]
@ -237,4 +322,5 @@ pub struct UpdateOfferInput {
pub description: String, pub description: String,
pub picture_url: String, pub picture_url: String,
pub state: OfferState, pub state: OfferState,
pub price_range: PriceRange,
} }

View File

@ -226,6 +226,8 @@ pub struct DeleteContactInfoInput {
pub struct CreateOfferInput { pub struct CreateOfferInput {
pub description: String, pub description: String,
pub picture_url: String, pub picture_url: String,
pub price_min: i32,
pub price_max: i32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -233,4 +235,6 @@ pub struct UpdateOfferInput {
pub id: i32, pub id: i32,
pub description: String, pub description: String,
pub picture_url: String, pub picture_url: String,
pub price_min: i32,
pub price_max: i32,
} }

View File

@ -1004,6 +1004,7 @@ pub async fn all_offers(t: &mut T<'_>) -> Result<Vec<db::Offer>> {
SELECT SELECT
id, id,
owner_id, owner_id,
price_range,
name, name,
description, description,
picture_url, picture_url,
@ -1028,6 +1029,7 @@ pub async fn visible_offers(t: &mut T<'_>) -> Result<Vec<db::Offer>> {
SELECT SELECT
id, id,
owner_id, owner_id,
price_range,
description, description,
picture_url, picture_url,
state, state,
@ -1052,6 +1054,7 @@ pub async fn account_offers(t: &mut T<'_>, account_id: i32) -> Result<Vec<db::Of
SELECT SELECT
id, id,
owner_id, owner_id,
price_range,
description, description,
picture_url, picture_url,
state, state,
@ -1074,11 +1077,12 @@ WHERE owner_id = $1
pub async fn create_offer(t: &mut T<'_>, input: db::CreateOfferInput) -> Result<db::Offer> { pub async fn create_offer(t: &mut T<'_>, input: db::CreateOfferInput) -> Result<db::Offer> {
sqlx::query_as( sqlx::query_as(
r#" r#"
INSERT INTO offers (description, picture_url, state, search, owner_id) INSERT INTO offers (description, picture_url, state, search, owner_id, price_range)
VALUES ($1, $2, $3, to_tsvector('polish', $4), $5) VALUES ($1, $2, $3, to_tsvector('polish', $4), $5, $6)
RETURNING RETURNING
id, id,
owner_id, owner_id,
price_range,
description, description,
picture_url, picture_url,
state, state,
@ -1090,6 +1094,7 @@ RETURNING
.bind(input.state) .bind(input.state)
.bind(&input.description) .bind(&input.description)
.bind(input.owner_id) .bind(input.owner_id)
.bind(input.price_range)
.fetch_one(t) .fetch_one(t)
.await .await
.map_err(|e| { .map_err(|e| {
@ -1107,11 +1112,13 @@ UPDATE offers
SET description = $2, SET description = $2,
picture_url = $3, picture_url = $3,
state = $4, state = $4,
search = to_tsvector('polish', $5) search = to_tsvector('polish', $5),
price_range = $6
WHERE id = $1 WHERE id = $1
RETURNING RETURNING
id, id,
owner_id, owner_id,
price_range,
description, description,
picture_url, picture_url,
state, state,
@ -1123,6 +1130,7 @@ RETURNING
.bind(&input.picture_url) .bind(&input.picture_url)
.bind(input.state) .bind(input.state)
.bind(&input.description) .bind(&input.description)
.bind(&input.price_range)
.fetch_one(t) .fetch_one(t)
.await .await
.map_err(|e| { .map_err(|e| {

View File

@ -3,9 +3,9 @@ use actix_web::{get, post, web, HttpResponse};
use askama::*; use askama::*;
use sqlx::PgPool; use sqlx::PgPool;
use crate::model::db::OfferState; use crate::model::db::{self, OfferState, PriceRange};
use crate::model::view;
use crate::model::view::Page; use crate::model::view::Page;
use crate::model::{db, view};
use crate::routes::{Identity, Result}; use crate::routes::{Identity, Result};
use crate::view::Helper; use crate::view::Helper;
use crate::{authorize, not_xss, ok_or_internal, queries}; use crate::{authorize, not_xss, ok_or_internal, queries};
@ -66,6 +66,7 @@ async fn create_offer(
picture_url: form.picture_url, picture_url: form.picture_url,
state: OfferState::Pending, state: OfferState::Pending,
owner_id: account.id, owner_id: account.id,
price_range: (form.price_min, form.price_max).into(),
}, },
) )
.await .await
@ -119,6 +120,7 @@ async fn update_offer(
description: form.description, description: form.description,
picture_url: form.picture_url, picture_url: form.picture_url,
state: Default::default(), state: Default::default(),
price_range: (form.price_min, form.price_max).into(),
}, },
) )
.await .await