basket logic working
This commit is contained in:
@@ -52,6 +52,7 @@ mod m20260623_000004_currencies;
|
|||||||
mod m20260625_000001_add_avatar_to_users;
|
mod m20260625_000001_add_avatar_to_users;
|
||||||
mod m20260627_000001_order_residence_address;
|
mod m20260627_000001_order_residence_address;
|
||||||
mod m20260627_000002_payment_settings;
|
mod m20260627_000002_payment_settings;
|
||||||
|
mod m20260627_000003_account_cart_items;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -108,6 +109,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260625_000001_add_avatar_to_users::Migration),
|
Box::new(m20260625_000001_add_avatar_to_users::Migration),
|
||||||
Box::new(m20260627_000001_order_residence_address::Migration),
|
Box::new(m20260627_000001_order_residence_address::Migration),
|
||||||
Box::new(m20260627_000002_payment_settings::Migration),
|
Box::new(m20260627_000002_payment_settings::Migration),
|
||||||
|
Box::new(m20260627_000003_account_cart_items::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
48
migration/src/m20260627_000003_account_cart_items.rs
Normal file
48
migration/src/m20260627_000003_account_cart_items.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"account_cart_items",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("variant_id", ColType::Integer),
|
||||||
|
("quantity", ColType::Integer),
|
||||||
|
],
|
||||||
|
&[("user", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk-account_cart_items-variant_id-to-product_variants")
|
||||||
|
.from(Alias::new("account_cart_items"), Alias::new("variant_id"))
|
||||||
|
.to(Alias::new("product_variants"), Alias::new("id"))
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::NoAction)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_account_cart_items_user_variant_unique")
|
||||||
|
.table(Alias::new("account_cart_items"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.col(Alias::new("variant_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "account_cart_items").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
controllers::cart,
|
||||||
models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
views::auth::{CurrentResponse, LoginResponse},
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
shared::guard::is_admin,
|
shared::guard::is_admin,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -88,6 +89,7 @@ pub struct ResendVerificationParams {
|
|||||||
/// welcome email to the user
|
/// welcome email to the user
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn register(
|
async fn register(
|
||||||
|
jar: CookieJar,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Json(params): Json<RegisterParams>,
|
Json(params): Json<RegisterParams>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
@@ -109,6 +111,7 @@ async fn register(
|
|||||||
.into_active_model()
|
.into_active_model()
|
||||||
.set_email_verification_sent(&ctx.db)
|
.set_email_verification_sent(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
|
||||||
|
|
||||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||||
|
|
||||||
@@ -199,8 +202,9 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
|
|||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +216,9 @@ async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Respo
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn logout() -> Result<Response> {
|
async fn logout() -> Result<Response> {
|
||||||
format::render().cookies(&[clear_auth_cookie()])?.json(())
|
format::render()
|
||||||
|
.cookies(&[clear_auth_cookie(), cart::cleared_cart_cookie()])?
|
||||||
|
.json(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||||
@@ -274,8 +280,9 @@ async fn magic_link_verify(
|
|||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::auth as auth_controller,
|
controllers::auth as auth_controller,
|
||||||
|
controllers::cart,
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
@@ -105,9 +106,13 @@ async fn login(
|
|||||||
let token = user
|
let token = user
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
|
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[
|
||||||
|
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
||||||
|
cart_cookie,
|
||||||
|
])?
|
||||||
.redirect(home_for(&ctx, &user))
|
.redirect(home_for(&ctx, &user))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +190,13 @@ async fn login_totp(
|
|||||||
let token = user
|
let token = user
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
|
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[
|
.cookies(&[
|
||||||
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
||||||
auth_controller::clear_totp_pending_cookie(),
|
auth_controller::clear_totp_pending_cookie(),
|
||||||
|
cart_cookie,
|
||||||
])?
|
])?
|
||||||
.redirect(home_for(&ctx, &user))
|
.redirect(home_for(&ctx, &user))
|
||||||
}
|
}
|
||||||
@@ -270,6 +277,7 @@ async fn register(
|
|||||||
.into_active_model()
|
.into_active_model()
|
||||||
.set_email_verification_sent(&ctx.db)
|
.set_email_verification_sent(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
|
||||||
|
|
||||||
// The account already exists; a failed email send shouldn't 500 the page —
|
// The account already exists; a failed email send shouldn't 500 the page —
|
||||||
// log it and let the user fall back to resend-verification.
|
// log it and let the user fall back to resend-verification.
|
||||||
@@ -304,7 +312,9 @@ async fn verify(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if user.email_verified_at.is_none() {
|
if user.email_verified_at.is_none() {
|
||||||
|
let user_id = user.id;
|
||||||
user.into_active_model().verified(&ctx.db).await?;
|
user.into_active_model().verified(&ctx.db).await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
verified_view(&v, &jar, true)
|
verified_view(&v, &jar, true)
|
||||||
@@ -446,7 +456,10 @@ async fn set_password(
|
|||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn logout() -> Result<Response> {
|
async fn logout() -> Result<Response> {
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
.cookies(&[
|
||||||
|
auth_controller::clear_auth_cookie(),
|
||||||
|
cart::cleared_cart_cookie(),
|
||||||
|
])?
|
||||||
.redirect("/login")
|
.redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}};
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{account_cart_items, product_variants, products, users},
|
||||||
|
shared::{
|
||||||
|
currency::{self, Currency},
|
||||||
|
guard, pricing,
|
||||||
|
},
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
@@ -64,6 +71,75 @@ fn cart_cookie(value: String) -> Cookie<'static> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cleared_cart_cookie() -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, ""))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(0))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_items(items: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
|
||||||
|
let mut normalized: Vec<(i32, i32)> = Vec::new();
|
||||||
|
for (id, qty) in items.into_iter().filter(|(_, qty)| *qty > 0) {
|
||||||
|
if let Some(existing) = normalized.iter_mut().find(|(existing_id, _)| *existing_id == id) {
|
||||||
|
existing.1 += qty;
|
||||||
|
} else {
|
||||||
|
normalized.push((id, qty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stored_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user: Option<&users::Model>,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<Vec<(i32, i32)>> {
|
||||||
|
match user {
|
||||||
|
Some(user) => Ok(account_cart_items::Model::find_for_user(&ctx.db, user.id).await?),
|
||||||
|
None => Ok(normalize_items(parse_cart(jar))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: CookieJar,
|
||||||
|
user: Option<&users::Model>,
|
||||||
|
items: &[(i32, i32)],
|
||||||
|
) -> Result<CookieJar> {
|
||||||
|
let items = normalize_items(items.to_vec());
|
||||||
|
if let Some(user) = user {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &items).await?;
|
||||||
|
}
|
||||||
|
Ok(jar.add(cart_cookie(serialize_cart(&items))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn claim_guest_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let items = normalize_items(parse_cart(jar));
|
||||||
|
if !items.is_empty() {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &items).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cart_cookie_for_user(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Cookie<'static>> {
|
||||||
|
let items = account_cart_items::Model::find_for_user(&ctx.db, user_id).await?;
|
||||||
|
Ok(cart_cookie(serialize_cart(&items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn clear_account_cart(ctx: &AppContext, user_id: i32) -> Result<()> {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &[]).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a variant whose product is published, returning the variant together
|
/// Look up a variant whose product is published, returning the variant together
|
||||||
/// with its parent product (for name/slug).
|
/// with its parent product (for name/slug).
|
||||||
async fn published_variant(
|
async fn published_variant(
|
||||||
@@ -94,7 +170,8 @@ async fn add(
|
|||||||
return Err(Error::NotFound);
|
return Err(Error::NotFound);
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
let add_qty = form.quantity.unwrap_or(1).max(1);
|
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
||||||
entry.1 = variant.cap(entry.1 + add_qty);
|
entry.1 = variant.cap(entry.1 + add_qty);
|
||||||
@@ -103,7 +180,7 @@ async fn add(
|
|||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
|
|
||||||
// Adding to the cart should never navigate away: htmx requests get an empty
|
// Adding to the cart should never navigate away: htmx requests get an empty
|
||||||
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
||||||
@@ -135,13 +212,14 @@ async fn update(
|
|||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
||||||
entry.1 = clamped;
|
entry.1 = clamped;
|
||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
cart_response(&ctx, &v, jar, &headers).await
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +231,11 @@ async fn remove(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Form(form): Form<RemoveForm>,
|
Form(form): Form<RemoveForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
items.retain(|(id, _)| *id != form.variant_id);
|
items.retain(|(id, _)| *id != form.variant_id);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
cart_response(&ctx, &v, jar, &headers).await
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +255,8 @@ async fn cart_response(
|
|||||||
let cur = currency::resolve(ctx, &jar).await;
|
let cur = currency::resolve(ctx, &jar).await;
|
||||||
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
|
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
|
||||||
// Persist the re-validated cookie (drops now-invalid lines).
|
// Persist the re-validated cookie (drops now-invalid lines).
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
|
let user = guard::current_user(ctx, &jar).await;
|
||||||
|
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
v,
|
v,
|
||||||
"shop/_cart_body.html",
|
"shop/_cart_body.html",
|
||||||
@@ -190,9 +270,9 @@ async fn cart_response(
|
|||||||
Ok((jar, response).into_response())
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
/// Resolve the active cart into priced line items, dropping anything that is no
|
||||||
/// longer purchasable and clamping quantities to current stock. Returns the
|
/// longer purchasable and clamping quantities to current stock. Guests resolve
|
||||||
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
|
/// from the cookie; authenticated users resolve from their account cart.
|
||||||
pub(crate) async fn resolve_cart(
|
pub(crate) async fn resolve_cart(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
@@ -202,7 +282,7 @@ pub(crate) async fn resolve_cart(
|
|||||||
// for the current viewer in one batch (the price depends on who's logged in).
|
// for the current viewer in one batch (the price depends on who's logged in).
|
||||||
let user = guard::current_user(ctx, jar).await;
|
let user = guard::current_user(ctx, jar).await;
|
||||||
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
|
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
|
||||||
for (id, qty) in parse_cart(jar) {
|
for (id, qty) in stored_cart(ctx, user.as_ref(), jar).await? {
|
||||||
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -238,6 +318,10 @@ pub(crate) async fn resolve_cart(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(user) = user.as_ref() {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok((lines, valid, total))
|
Ok((lines, valid, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,8 +334,6 @@ async fn show(
|
|||||||
let cur = currency::resolve(&ctx, &jar).await;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||||
|
|
||||||
// Drop any now-invalid lines from the cookie so the badge stays accurate.
|
|
||||||
let rebuilt = serialize_cart(&valid);
|
|
||||||
let c = guard::chrome(&ctx, &jar).await;
|
let c = guard::chrome(&ctx, &jar).await;
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
&v,
|
&v,
|
||||||
@@ -269,7 +351,9 @@ async fn show(
|
|||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
||||||
@@ -282,7 +366,6 @@ async fn preview(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let cur = currency::resolve(&ctx, &jar).await;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||||
let rebuilt = serialize_cart(&valid);
|
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/_cart_preview.html",
|
"shop/_cart_preview.html",
|
||||||
@@ -293,7 +376,9 @@ async fn preview(
|
|||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
//! confirmation page.
|
//! confirmation page.
|
||||||
|
|
||||||
use axum::extract::Query;
|
use axum::extract::Query;
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::Duration as TimeDuration;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::cart::{resolve_cart, CART_COOKIE},
|
controllers::cart::{self, resolve_cart},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
models::{
|
models::{
|
||||||
customer_profiles::{self, ProfileFields},
|
customer_profiles::{self, ProfileFields},
|
||||||
@@ -58,14 +56,6 @@ fn trimmed(value: &str) -> Option<String> {
|
|||||||
(!value.is_empty()).then(|| value.to_string())
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleared_cart_cookie() -> Cookie<'static> {
|
|
||||||
Cookie::build((CART_COOKIE, ""))
|
|
||||||
.path("/")
|
|
||||||
.same_site(SameSite::Lax)
|
|
||||||
.max_age(TimeDuration::seconds(0))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
||||||
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
|
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
|
||||||
let packeta_ready = shipping_rules::packeta_ready(ctx);
|
let packeta_ready = shipping_rules::packeta_ready(ctx);
|
||||||
@@ -388,8 +378,11 @@ async fn place_order(
|
|||||||
} else {
|
} else {
|
||||||
format!("/orders/{}", order.order_number)
|
format!("/orders/{}", order.order_number)
|
||||||
};
|
};
|
||||||
|
if let Some(user) = logged_in_customer {
|
||||||
|
cart::clear_account_cart(&ctx, user.id).await?;
|
||||||
|
}
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[cleared_cart_cookie()])?
|
.cookies(&[cart::cleared_cart_cookie()])?
|
||||||
.redirect(&target)
|
.redirect(&target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use loco_rs::prelude::*;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::auth as auth_controller,
|
controllers::auth as auth_controller,
|
||||||
|
controllers::cart,
|
||||||
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
|
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
|
||||||
shared::guard,
|
shared::guard,
|
||||||
};
|
};
|
||||||
@@ -36,8 +37,9 @@ async fn complete(State(ctx): State<AppContext>, user: GoogleCookieUser) -> Resu
|
|||||||
} else {
|
} else {
|
||||||
"/"
|
"/"
|
||||||
};
|
};
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.redirect(dest)
|
.redirect(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
src/models/_entities/account_cart_items.rs
Normal file
48
src/models/_entities/account_cart_items.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "account_cart_items")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub variant_id: i32,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub user_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::product_variants::Entity",
|
||||||
|
from = "Column::VariantId",
|
||||||
|
to = "super::product_variants::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
ProductVariants,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_variants::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ProductVariants.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod account_cart_items;
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
pub use super::account_cart_items::Entity as AccountCartItems;
|
||||||
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
||||||
pub use super::account_product_prices::Entity as AccountProductPrices;
|
pub use super::account_product_prices::Entity as AccountProductPrices;
|
||||||
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub struct Model {
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::account_cart_items::Entity")]
|
||||||
|
AccountCartItems,
|
||||||
#[sea_orm(has_many = "super::account_product_prices::Entity")]
|
#[sea_orm(has_many = "super::account_product_prices::Entity")]
|
||||||
AccountProductPrices,
|
AccountProductPrices,
|
||||||
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
|
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
|
||||||
@@ -38,6 +40,12 @@ pub enum Relation {
|
|||||||
Products,
|
Products,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::account_cart_items::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::AccountCartItems.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Related<super::account_product_prices::Entity> for Entity {
|
impl Related<super::account_product_prices::Entity> for Entity {
|
||||||
fn to() -> RelationDef {
|
fn to() -> RelationDef {
|
||||||
Relation::AccountProductPrices.def()
|
Relation::AccountProductPrices.def()
|
||||||
|
|||||||
55
src/models/account_cart_items.rs
Normal file
55
src/models/account_cart_items.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
pub use crate::models::_entities::account_cart_items::{ActiveModel, Column, Entity, Model};
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue, QueryFilter, QueryOrder};
|
||||||
|
|
||||||
|
pub type AccountCartItems = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub async fn find_for_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Vec<(i32, i32)>, DbErr> {
|
||||||
|
Ok(Entity::find()
|
||||||
|
.filter(Column::UserId.eq(user_id))
|
||||||
|
.order_by_asc(Column::Id)
|
||||||
|
.all(db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| (item.quantity > 0).then_some((item.variant_id, item.quantity)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn replace_for_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
user_id: i32,
|
||||||
|
items: &[(i32, i32)],
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
Entity::delete_many()
|
||||||
|
.filter(Column::UserId.eq(user_id))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (variant_id, quantity) in items.iter().copied().filter(|(_, qty)| *qty > 0) {
|
||||||
|
ActiveModel {
|
||||||
|
user_id: ActiveValue::set(user_id),
|
||||||
|
variant_id: ActiveValue::set(variant_id),
|
||||||
|
quantity: ActiveValue::set(quantity),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
pub mod _entities;
|
pub mod _entities;
|
||||||
|
|
||||||
|
pub mod account_cart_items;
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
|
|||||||
Reference in New Issue
Block a user