diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl
index 31f2898..591e9c0 100644
--- a/assets/i18n/en/main.ftl
+++ b/assets/i18n/en/main.ftl
@@ -70,6 +70,7 @@ auth-or = or
auth-google = Continue with Google
nav-login = Sign in
nav-register = Register
+nav-profile = My profile
register-title = Create account
register-name = Name
register-submit = Create account
@@ -256,8 +257,13 @@ country-de = Germany
country-pl = Poland
country-hu = Hungary
checkout-note = Order note
+checkout-save-profile = Save this address to my profile
checkout-place-order = Place order
checkout-summary = Order summary
+profile-title = My profile
+profile-intro = We'll use these details to prefill checkout.
+profile-saved = Profile saved.
+profile-save = Save profile
order-confirmed-title = Thank you for your order!
order-confirmed-sub = We have received your order.
order-number = Order number
diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl
index 3962cfd..88c5c83 100644
--- a/assets/i18n/sk/main.ftl
+++ b/assets/i18n/sk/main.ftl
@@ -70,6 +70,7 @@ auth-or = alebo
auth-google = Pokračovať cez Google
nav-login = Prihlásiť sa
nav-register = Registrácia
+nav-profile = Môj profil
register-title = Vytvoriť účet
register-name = Meno
register-submit = Zaregistrovať sa
@@ -256,8 +257,13 @@ country-de = Nemecko
country-pl = Poľsko
country-hu = Maďarsko
checkout-note = Poznámka k objednávke
+checkout-save-profile = Uložiť túto adresu do môjho profilu
checkout-place-order = Odoslať objednávku
checkout-summary = Súhrn objednávky
+profile-title = Môj profil
+profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
+profile-saved = Profil bol uložený.
+profile-save = Uložiť profil
order-confirmed-title = Ďakujeme za objednávku!
order-confirmed-sub = Vašu objednávku sme prijali.
order-number = Číslo objednávky
diff --git a/assets/views/account/profile.html b/assets/views/account/profile.html
new file mode 100644
index 0000000..0e10064
--- /dev/null
+++ b/assets/views/account/profile.html
@@ -0,0 +1,114 @@
+{% extends "base.html" %}
+{% import "macros/ui.html" as ui %}
+
+{% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %}
+
+{% block content %}
+
+
{{ t(key="profile-title", lang=lang | default(value='sk')) }}
+
{{ t(key="profile-intro", lang=lang | default(value='sk')) }}
+
+ {% if saved %}
+
+ {{ t(key="profile-saved", lang=lang | default(value='sk')) }}
+
+ {% endif %}
+
+
+
+{% endblock content %}
diff --git a/assets/views/base.html b/assets/views/base.html
index d4e2454..43baef3 100644
--- a/assets/views/base.html
+++ b/assets/views/base.html
@@ -85,6 +85,13 @@
+ {% elif logged_in_customer %}
+ {{ ui::nav_link(label=t(key="nav-profile", lang=lang | default(value='sk')), href="/account/profile", data_nav="/account") }}
+
+
+
{% else %}
{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}
{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}
@@ -131,6 +138,13 @@
+ {% elif logged_in_customer %}
+ {{ t(key="nav-profile", lang=lang | default(value='sk')) }}
+
+
+
{% else %}
{{ t(key="nav-login", lang=lang | default(value='sk')) }}
{{ t(key="nav-register", lang=lang | default(value='sk')) }}
diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout.html
index c28c8f4..8ccc3f2 100644
--- a/assets/views/shop/checkout.html
+++ b/assets/views/shop/checkout.html
@@ -36,18 +36,18 @@
- {{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
+ {{ ui::input(name="email", id="email", type="email", value=prefill_email | default(value=''), required=true, autocomplete="email") }}
- {{ ui::input(name="customer_name", id="customer_name", required=true, autocomplete="name") }}
+ {{ ui::input(name="customer_name", id="customer_name", value=prefill_name | default(value=''), required=true, autocomplete="name") }}
- {{ ui::input(name="phone", id="phone", type="tel", required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
+ {{ ui::input(name="phone", id="phone", type="tel", value=prefill_phone | default(value=''), required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
@@ -81,21 +81,21 @@
- {{ ui::input(name="address", id="address", required=true, autocomplete="street-address") }}
+ {{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
- {{ ui::input(name="city", id="city", required=true, autocomplete="address-level2") }}
+ {{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
- {{ ui::input(name="zip", id="zip", required=true, autocomplete="postal-code") }}
+ {{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
{{ t(key="checkout-note", lang=lang | default(value='sk')) }}
{{ ui::textarea(name="note", id="note", rows="3") }}
+
+ {% if logged_in_customer %}
+
+ {{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk')), checked=true) }}
+ {% endif %}
diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index 8db63e6..2594493 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -31,6 +31,7 @@ mod m20260617_000001_add_carrier_to_shipping_methods;
mod m20260617_000002_add_shipment_to_orders;
mod m20260617_000003_add_phone_to_orders;
mod m20260618_000001_o_auth2_sessions;
+mod m20260618_000002_customer_profiles;
pub struct Migrator;
#[async_trait::async_trait]
@@ -66,6 +67,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260617_000002_add_shipment_to_orders::Migration),
Box::new(m20260617_000003_add_phone_to_orders::Migration),
Box::new(m20260618_000001_o_auth2_sessions::Migration),
+ Box::new(m20260618_000002_customer_profiles::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/migration/src/m20260618_000002_customer_profiles.rs b/migration/src/m20260618_000002_customer_profiles.rs
new file mode 100644
index 0000000..2e1b372
--- /dev/null
+++ b/migration/src/m20260618_000002_customer_profiles.rs
@@ -0,0 +1,44 @@
+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> {
+ // One shipping/contact profile per customer, used to prefill the
+ // checkout form. `name`/`email` already live on `users`; this table
+ // holds only the address + phone fields. `user` adds a user_id FK; the
+ // unique index below makes the relationship 1:1.
+ create_table(
+ m,
+ "customer_profiles",
+ &[
+ ("id", ColType::PkAuto),
+ ("phone_prefix", ColType::StringNull),
+ ("phone", ColType::StringNull),
+ ("address", ColType::StringNull),
+ ("city", ColType::StringNull),
+ ("zip", ColType::StringNull),
+ ("country", ColType::StringNull),
+ ],
+ &[("user", "")],
+ )
+ .await?;
+
+ m.create_index(
+ Index::create()
+ .name("idx_customer_profiles_user_id_unique")
+ .table(Alias::new("customer_profiles"))
+ .col(Alias::new("user_id"))
+ .unique()
+ .to_owned(),
+ )
+ .await
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ drop_table(m, "customer_profiles").await
+ }
+}
diff --git a/src/app.rs b/src/app.rs
index a4e00e9..50f2f7c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -17,7 +17,7 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers::{
- admin_categories, admin_dashboard, admin_form, admin_orders,
+ account, admin_categories, admin_dashboard, admin_form, admin_orders,
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
shop,
},
@@ -91,6 +91,7 @@ impl Hooks for App {
// cross-cutting
.add_route(auth::routes())
.add_route(auth_pages::routes())
+ .add_route(account::routes())
.add_route(oauth2::routes())
.add_route(i18n::routes())
.add_route(media::routes())
diff --git a/src/controllers/account.rs b/src/controllers/account.rs
new file mode 100644
index 0000000..9f6ea6e
--- /dev/null
+++ b/src/controllers/account.rs
@@ -0,0 +1,112 @@
+//! Customer account area. Currently just the shipping/contact profile, whose
+//! fields prefill the checkout form. Gated to authenticated non-admin users:
+//! anonymous visitors are bounced to `/login`. Admins have their own area and
+//! are sent to the dashboard.
+
+use axum_extra::extract::cookie::CookieJar;
+use loco_rs::prelude::*;
+use serde::Deserialize;
+use serde_json::json;
+
+use crate::{
+ controllers::i18n::current_lang,
+ models::customer_profiles::{self, ProfileFields},
+ shared::guard,
+};
+
+#[derive(Debug, Deserialize)]
+struct ProfileForm {
+ phone_prefix: Option
,
+ phone: Option,
+ address: Option,
+ city: Option,
+ zip: Option,
+ country: Option,
+}
+
+fn trimmed(value: Option<&str>) -> Option {
+ value.map(str::trim).filter(|v| !v.is_empty()).map(String::from)
+}
+
+impl From for ProfileFields {
+ fn from(form: ProfileForm) -> Self {
+ Self {
+ phone_prefix: trimmed(form.phone_prefix.as_deref()),
+ phone: trimmed(form.phone.as_deref()),
+ address: trimmed(form.address.as_deref()),
+ city: trimmed(form.city.as_deref()),
+ zip: trimmed(form.zip.as_deref()),
+ country: trimmed(form.country.as_deref()),
+ }
+ }
+}
+
+/// Render the profile form for `profile` (which may be `None` for a customer
+/// who hasn't saved anything yet). `saved` shows the success banner after a
+/// POST.
+fn profile_view(
+ v: &TeraView,
+ jar: &CookieJar,
+ name: &str,
+ email: &str,
+ profile: Option<&customer_profiles::Model>,
+ saved: bool,
+) -> Result {
+ format::view(
+ v,
+ "account/profile.html",
+ json!({
+ "logged_in_admin": false,
+ "logged_in_customer": true,
+ "saved": saved,
+ "name": name,
+ "email": email,
+ "phone_prefix": profile.and_then(|p| p.phone_prefix.clone()),
+ "phone": profile.and_then(|p| p.phone.clone()),
+ "address": profile.and_then(|p| p.address.clone()),
+ "city": profile.and_then(|p| p.city.clone()),
+ "zip": profile.and_then(|p| p.zip.clone()),
+ "country": profile.and_then(|p| p.country.clone()),
+ "lang": current_lang(jar),
+ }),
+ )
+}
+
+#[debug_handler]
+async fn profile_page(
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+) -> Result {
+ let Some(user) = guard::current_user(&ctx, &jar).await else {
+ return format::redirect("/login");
+ };
+ if guard::is_admin(&ctx, &user) {
+ return format::redirect("/admin/dashboard");
+ }
+ let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
+ profile_view(&v, &jar, &user.name, &user.email, profile.as_ref(), false)
+}
+
+#[debug_handler]
+async fn save_profile(
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ let Some(user) = guard::current_user(&ctx, &jar).await else {
+ return format::redirect("/login");
+ };
+ if guard::is_admin(&ctx, &user) {
+ return format::redirect("/admin/dashboard");
+ }
+ let profile = customer_profiles::Model::upsert(&ctx.db, user.id, form.into()).await?;
+ profile_view(&v, &jar, &user.name, &user.email, Some(&profile), true)
+}
+
+pub fn routes() -> Routes {
+ Routes::new()
+ .add("/account/profile", get(profile_page))
+ .add("/account/profile", post(save_profile))
+}
diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs
index a3c7ae3..e181a95 100644
--- a/src/controllers/cart.rs
+++ b/src/controllers/cart.rs
@@ -1,4 +1,4 @@
-use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
+use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price}, models::products};
use axum::{
http::{HeaderMap, StatusCode},
response::Redirect,
@@ -234,6 +234,7 @@ async fn show(
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
+ let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
let response = format::view(
&v,
"shop/cart.html",
@@ -241,6 +242,8 @@ async fn show(
"items": lines,
"total": format_price(total),
"currency": currency,
+ "logged_in_admin": logged_in_admin,
+ "logged_in_customer": logged_in_customer,
"lang": current_lang(&jar),
}),
)?;
diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs
index 9375273..37a627e 100644
--- a/src/controllers/checkout.rs
+++ b/src/controllers/checkout.rs
@@ -10,9 +10,9 @@ use time::Duration as TimeDuration;
use crate::{
controllers::cart::{resolve_cart, CART_COOKIE},
- models::{order_items, orders, shipping_methods},
+ models::{customer_profiles::{self, ProfileFields}, order_items, orders, shipping_methods},
controllers::i18n::current_lang,
- shared::{money::format_price, settings},
+ shared::{guard, money::format_price, settings},
views::checkout as view,
};
@@ -33,6 +33,8 @@ struct CheckoutForm {
carrier_code: String,
pickup_point_id: Option,
pickup_point_name: Option,
+ // Present (as "on") only when a logged-in customer ticks "save my address".
+ save_profile: Option,
}
fn trimmed(value: &str) -> Option {
@@ -86,6 +88,19 @@ async fn checkout_page(
})
.collect();
+ // Prefill the form for a logged-in customer: contact name/email come from
+ // the user account, the address/phone from their saved profile (if any).
+ let user = guard::current_user(&ctx, &jar).await;
+ let is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u));
+ let is_customer = user.is_some() && !is_admin;
+ let profile = match (&user, is_customer) {
+ (Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
+ _ => None,
+ };
+ let p = |get: fn(&customer_profiles::Model) -> Option| {
+ profile.as_ref().and_then(get)
+ };
+
format::view(
&v,
"shop/checkout.html",
@@ -96,6 +111,16 @@ async fn checkout_page(
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
+ "logged_in_admin": is_admin,
+ "logged_in_customer": is_customer,
+ "prefill_email": user.as_ref().filter(|_| is_customer).map(|u| u.email.clone()),
+ "prefill_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
+ "prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
+ "prefill_phone": p(|x| x.phone.clone()),
+ "prefill_address": p(|x| x.address.clone()),
+ "prefill_city": p(|x| x.city.clone()),
+ "prefill_zip": p(|x| x.zip.clone()),
+ "prefill_country": p(|x| x.country.clone()),
"lang": current_lang(&jar),
}),
)
@@ -119,7 +144,7 @@ async fn place_order(
trimmed(&form.phone).ok_or_else(|| Error::BadRequest("phone is required".to_string()))?;
let phone = match trimmed(&form.phone_prefix) {
Some(prefix) => format!("{prefix} {number}"),
- None => number,
+ None => number.clone(),
};
// Contact and shipping-address fields are mandatory (also enforced in the
@@ -157,6 +182,28 @@ async fn place_order(
(None, None)
};
+ // If a logged-in customer opted in, persist this address to their profile
+ // so the next checkout is prefilled. Phone is stored split (prefix + number)
+ // to match the profile/checkout fields. Best-effort: a failure here is logged
+ // but must not block the order.
+ if form.save_profile.is_some() {
+ if let Some(user) = guard::current_user(&ctx, &jar).await {
+ if !guard::is_admin(&ctx, &user) {
+ let fields = ProfileFields {
+ phone_prefix: trimmed(&form.phone_prefix),
+ phone: Some(number.clone()),
+ address: Some(address.clone()),
+ city: Some(city.clone()),
+ zip: Some(zip.clone()),
+ country: Some(country.clone()),
+ };
+ if let Err(err) = customer_profiles::Model::upsert(&ctx.db, user.id, fields).await {
+ tracing::error!(error = %err, user_id = user.id, "failed to save checkout profile");
+ }
+ }
+ }
+ }
+
let order = orders::place(
&ctx,
&valid,
@@ -198,6 +245,7 @@ async fn order_confirmation(
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.await?;
+ let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
format::view(
&v,
@@ -209,6 +257,8 @@ async fn order_confirmation(
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
+ "logged_in_admin": logged_in_admin,
+ "logged_in_customer": logged_in_customer,
"lang": current_lang(&jar),
}),
)
diff --git a/src/controllers/home.rs b/src/controllers/home.rs
index 9eb71e9..a526b68 100644
--- a/src/controllers/home.rs
+++ b/src/controllers/home.rs
@@ -13,13 +13,15 @@ async fn index(
State(ctx): State,
) -> Result {
let products = shop::featured_products(&ctx, 8).await?;
+ let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"home/index.html",
json!({
"products": products,
- "logged_in_admin": guard::logged_in(&ctx, &jar).await,
+ "logged_in_admin": logged_in_admin,
+ "logged_in_customer": logged_in_customer,
"lang": current_lang(&jar),
}),
)
diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs
index 85d8ed5..c02c866 100644
--- a/src/controllers/mod.rs
+++ b/src/controllers/mod.rs
@@ -1,3 +1,4 @@
+pub mod account;
pub mod auth;
pub mod auth_pages;
pub mod oauth2;
diff --git a/src/controllers/shop.rs b/src/controllers/shop.rs
index c32453b..4bdc713 100644
--- a/src/controllers/shop.rs
+++ b/src/controllers/shop.rs
@@ -69,12 +69,14 @@ async fn index(
.all(&ctx.db)
.await?;
+ let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/index.html",
json!({
"products": product_rows(&ctx, list).await?,
- "logged_in_admin": guard::logged_in(&ctx, &jar).await,
+ "logged_in_admin": logged_in_admin,
+ "logged_in_customer": logged_in_customer,
"lang": current_lang(&jar),
}),
)
@@ -108,6 +110,7 @@ async fn show(
None => None,
};
+ let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/show.html",
@@ -115,7 +118,8 @@ async fn show(
"product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())),
"images": images.iter().map(|i| i.image_id.clone()).collect::>(),
"category": category,
- "logged_in_admin": guard::logged_in(&ctx, &jar).await,
+ "logged_in_admin": logged_in_admin,
+ "logged_in_customer": logged_in_customer,
"lang": current_lang(&jar),
}),
)
@@ -151,6 +155,7 @@ async fn category(
.all(&ctx.db)
.await?;
+ let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/category.html",
@@ -159,7 +164,8 @@ async fn category(
"breadcrumbs": breadcrumbs,
"children": children,
"products": product_rows(&ctx, list).await?,
- "logged_in_admin": guard::logged_in(&ctx, &jar).await,
+ "logged_in_admin": logged_in_admin,
+ "logged_in_customer": logged_in_customer,
"lang": current_lang(&jar),
}),
)
diff --git a/src/models/_entities/customer_profiles.rs b/src/models/_entities/customer_profiles.rs
new file mode 100644
index 0000000..9ea4f85
--- /dev/null
+++ b/src/models/_entities/customer_profiles.rs
@@ -0,0 +1,40 @@
+//! `SeaORM` Entity for customer shipping/contact profiles. Hand-written to match
+//! the `customer_profiles` migration (1:1 with `users` via a unique `user_id`).
+
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
+#[sea_orm(table_name = "customer_profiles")]
+pub struct Model {
+ pub created_at: DateTimeWithTimeZone,
+ pub updated_at: DateTimeWithTimeZone,
+ #[sea_orm(primary_key)]
+ pub id: i32,
+ #[sea_orm(unique)]
+ pub user_id: i32,
+ pub phone_prefix: Option,
+ pub phone: Option,
+ pub address: Option,
+ pub city: Option,
+ pub zip: Option,
+ pub country: Option,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::users::Entity",
+ from = "Column::UserId",
+ to = "super::users::Column::Id",
+ on_update = "Cascade",
+ on_delete = "Cascade"
+ )]
+ Users,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Users.def()
+ }
+}
diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs
index 660d322..410f35b 100644
--- a/src/models/_entities/mod.rs
+++ b/src/models/_entities/mod.rs
@@ -4,6 +4,7 @@ pub mod prelude;
pub mod audit_logs;
pub mod categories;
+pub mod customer_profiles;
pub mod o_auth2_sessions;
pub mod order_items;
pub mod orders;
diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs
index 7f2b205..dde8846 100644
--- a/src/models/_entities/prelude.rs
+++ b/src/models/_entities/prelude.rs
@@ -2,6 +2,7 @@
pub use super::audit_logs::Entity as AuditLogs;
pub use super::categories::Entity as Categories;
+pub use super::customer_profiles::Entity as CustomerProfiles;
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders;
diff --git a/src/models/customer_profiles.rs b/src/models/customer_profiles.rs
new file mode 100644
index 0000000..20a8419
--- /dev/null
+++ b/src/models/customer_profiles.rs
@@ -0,0 +1,64 @@
+//! Per-customer shipping/contact profile: the address + phone fields used to
+//! prefill checkout. One row per user (unique `user_id`); `name`/`email` are
+//! read from `users`, never duplicated here.
+
+pub use crate::models::_entities::customer_profiles::{ActiveModel, Column, Entity, Model};
+use sea_orm::entity::prelude::*;
+use sea_orm::{ActiveValue, IntoActiveModel, QueryFilter, TryIntoModel};
+
+pub type CustomerProfiles = Entity;
+
+/// The editable profile fields, shared by the profile page and the checkout
+/// "save my address" path.
+#[derive(Debug, Default, Clone)]
+pub struct ProfileFields {
+ pub phone_prefix: Option,
+ pub phone: Option,
+ pub address: Option,
+ pub city: Option,
+ pub zip: Option,
+ pub country: Option,
+}
+
+#[async_trait::async_trait]
+impl ActiveModelBehavior for ActiveModel {
+ async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result
+ where
+ C: ConnectionTrait,
+ {
+ Ok(self)
+ }
+}
+
+impl Model {
+ /// The profile for `user_id`, if one exists.
+ pub async fn find_for_user(db: &DatabaseConnection, user_id: i32) -> Result