CZK implemented
This commit is contained in:
@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
|
||||
logout = Log out
|
||||
settings = Settings
|
||||
settings-language = Language
|
||||
settings-currency = Currency
|
||||
settings-theme = Theme
|
||||
language-en = English
|
||||
language-sk = Slovak
|
||||
@@ -475,6 +476,13 @@ bank-amount = Amount
|
||||
admin-shipping = Shipping
|
||||
admin-shipping-desc = set the price and availability of each delivery option.
|
||||
shipping-enabled = Active
|
||||
admin-currency = Currency
|
||||
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
|
||||
exchange-rate = Exchange rate
|
||||
exchange-rate-hint = { $code } prices are the { $base } price recalculated at this rate.
|
||||
currency-enabled = Available to customers
|
||||
currency-base = Base currency
|
||||
currency-base-hint = the currency you enter prices in and settle payment in. Cannot be changed.
|
||||
shipping-new = Add delivery option
|
||||
shipping-add = Add
|
||||
shipping-requires-pickup = Requires pickup point
|
||||
|
||||
@@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
|
||||
logout = Odhlásiť sa
|
||||
settings = Nastavenia
|
||||
settings-language = Jazyk
|
||||
settings-currency = Mena
|
||||
settings-theme = Téma
|
||||
language-en = Angličtina
|
||||
language-sk = Slovenčina
|
||||
@@ -475,6 +476,13 @@ bank-amount = Suma
|
||||
admin-shipping = Doprava
|
||||
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
||||
shipping-enabled = Aktívne
|
||||
admin-currency = Mena
|
||||
admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR.
|
||||
exchange-rate = Výmenný kurz
|
||||
exchange-rate-hint = ceny v { $code } sa prepočítajú z ceny v { $base } týmto kurzom.
|
||||
currency-enabled = Dostupná pre zákazníkov
|
||||
currency-base = Základná mena
|
||||
currency-base-hint = mena, v ktorej zadávate ceny a prebieha platba. Nedá sa zmeniť.
|
||||
shipping-new = Pridať možnosť dopravy
|
||||
shipping-add = Pridať
|
||||
shipping-requires-pickup = Vyžaduje výdajné miesto
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/currencies" data-nav="/admin/currencies"
|
||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-currency", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||
|
||||
44
assets/views/admin/currencies/index.html
Normal file
44
assets/views/admin/currencies/index.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-currency", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-currency-desc", lang=lang | default(value='sk')) }}</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<!-- base currency, read-only for context -->
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-radius border border-outline bg-surface-alt/40 p-5 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
||||
<div class="min-w-40">
|
||||
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ base_code }} ({{ base_symbol }})</p>
|
||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="currency-base-hint", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
{{ ui::badge(label=t(key="currency-base", lang=lang | default(value='sk')), variant="neutral") }}
|
||||
</div>
|
||||
|
||||
{% for c in currencies %}
|
||||
<form method="post" action="/admin/currencies/{{ c.id }}"
|
||||
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
{{ ui::csrf_field() }}
|
||||
<div class="min-w-40">
|
||||
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ c.code }} ({{ c.symbol }})</p>
|
||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="exchange-rate-hint", code=c.code, base=base_code, lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="rate-{{ c.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="exchange-rate", lang=lang | default(value='sk')) }}</label>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">1 {{ base_code }} =</span>
|
||||
{{ ui::input(name="rate", id="rate-" ~ c.id, value=c.rate, width="w-28", attrs='inputmode="decimal"') }}
|
||||
<span class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ c.code }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="currency-enabled", lang=lang | default(value='sk')), checked=c.enabled) }}</div>
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -35,6 +35,27 @@
|
||||
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{# Currency switcher. The active code is read from the `currency` cookie
|
||||
client-side (Alpine), so this partial needs no per-page server data; posting
|
||||
to /currency sets the cookie and reloads. EUR is the base; CZK prices are the
|
||||
EUR price recalculated at the admin-set rate. #}
|
||||
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-currency", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<form method="post" action="/currency" hx-boost="false"
|
||||
x-data="{ cur: ((document.cookie.split('; ').find(function (c) { return c.indexOf('currency=') === 0 }) || 'currency=EUR').split('=')[1]) }">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
<button type="submit" name="currency" value="EUR" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>EUR (€)</span>
|
||||
<span x-cloak x-show="cur === 'EUR'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="submit" name="currency" value="CZK" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>CZK (Kč)</span>
|
||||
<span x-cloak x-show="cur === 'CZK'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
|
||||
@@ -38,11 +38,11 @@
|
||||
{% endif %}
|
||||
{% if product.on_sale %}
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
|
||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} €</span>
|
||||
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} €</span>
|
||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ currency_symbol }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} €</span>
|
||||
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
</td>
|
||||
<td class="px-4 py-3 tabular-nums">
|
||||
{% if item.on_sale %}
|
||||
<span class="font-medium text-danger">{{ item.price }} €</span>
|
||||
<span class="font-medium text-danger">{{ item.price }} {{ currency_symbol }}</span>
|
||||
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
|
||||
{% else %}
|
||||
{{ item.price }} €
|
||||
{{ item.price }} {{ currency_symbol }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@@ -48,7 +48,7 @@
|
||||
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} €</td>
|
||||
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ currency_symbol }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="post" action="/cart/remove"
|
||||
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
|
||||
@@ -63,7 +63,7 @@
|
||||
<tfoot class="{{ ui::tfoot_cls() }}">
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
||||
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} €</td>
|
||||
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
|
||||
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} €</p>
|
||||
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} €</span>
|
||||
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ currency_symbol }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} €</span>
|
||||
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<!-- price band -->
|
||||
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
|
||||
{{ t(key="filter-price", lang=L) }}
|
||||
{{ t(key="filter-price", lang=L) }}{% if currency_symbol %} ({{ currency_symbol }}){% endif %}
|
||||
<span class="flex items-center gap-1">
|
||||
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
|
||||
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
||||
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
||||
<template x-for="(v, i) in variants" :key="v.id">
|
||||
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' €' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
||||
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ currency_symbol }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
@@ -75,10 +75,10 @@
|
||||
|
||||
<div class="flex items-baseline gap-3">
|
||||
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
||||
<span x-text="current.price"></span> €
|
||||
<span x-text="current.price"></span> {{ currency_symbol }}
|
||||
</p>
|
||||
<template x-if="current.on_sale">
|
||||
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> €</p>
|
||||
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ currency_symbol }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ mod m20260622_000006_order_search_indexes;
|
||||
mod m20260623_000001_add_short_description_to_products;
|
||||
mod m20260623_000002_strip_html_from_product_search;
|
||||
mod m20260623_000003_drop_currency;
|
||||
mod m20260623_000004_currencies;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -100,6 +101,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260623_000001_add_short_description_to_products::Migration),
|
||||
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
|
||||
Box::new(m20260623_000003_drop_currency::Migration),
|
||||
Box::new(m20260623_000004_currencies::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
31
migration/src/m20260623_000004_currencies.rs
Normal file
31
migration/src/m20260623_000004_currencies.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
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> {
|
||||
// Buyer-selectable display currencies. EUR is the base/transaction
|
||||
// currency and is NOT stored here; each row is an alternative the buyer
|
||||
// can switch to, whose prices are the EUR price recalculated at
|
||||
// `rate_e4` (units of this currency per 1 EUR, scaled ×10000). For now
|
||||
// the only row is CZK, seeded by `initializers::currency_seeder`.
|
||||
create_table(m, "currencies",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("code", ColType::StringUniq),
|
||||
("symbol", ColType::String),
|
||||
("rate_e4", ColType::BigIntegerWithDefault(10_000)),
|
||||
("enabled", ColType::BooleanWithDefault(true)),
|
||||
],
|
||||
&[
|
||||
]
|
||||
).await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
drop_table(m, "currencies").await
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@ use std::{path::Path, sync::Arc};
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
controllers::{
|
||||
account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles,
|
||||
admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
|
||||
cart, checkout, home, i18n, media, oauth2,
|
||||
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
|
||||
admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping,
|
||||
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
|
||||
shop,
|
||||
},
|
||||
initializers,
|
||||
@@ -83,6 +83,7 @@ impl Hooks for App {
|
||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||
Box::new(initializers::currency_seeder::CurrencySeeder),
|
||||
Box::new(initializers::oauth2::OAuth2StoreInitializer),
|
||||
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
|
||||
])
|
||||
@@ -95,6 +96,7 @@ impl Hooks for App {
|
||||
.add_route(shop::routes())
|
||||
.add_route(cart::routes())
|
||||
.add_route(checkout::routes())
|
||||
.add_route(currency::routes())
|
||||
// cross-cutting
|
||||
.add_route(auth::routes())
|
||||
.add_route(auth_pages::routes())
|
||||
@@ -110,6 +112,7 @@ impl Hooks for App {
|
||||
.add_route(admin_orders::routes())
|
||||
.add_route(admin_customers::routes())
|
||||
.add_route(admin_shipping::routes())
|
||||
.add_route(admin_currencies::routes())
|
||||
}
|
||||
|
||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||
|
||||
92
src/controllers/admin_currencies.rs
Normal file
92
src/controllers/admin_currencies.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Admin management of the alternative display currencies.
|
||||
//!
|
||||
//! EUR is the base/transaction currency and is shown read-only for context. The
|
||||
//! admin sets each alternative currency's exchange rate (units per 1 EUR) and
|
||||
//! toggles whether buyers may switch to it. The currencies themselves are fixed
|
||||
//! and seeded by `initializers::currency_seeder`.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
models::currencies,
|
||||
shared::{
|
||||
currency::{self, BASE_CODE, BASE_SYMBOL},
|
||||
guard,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CurrencyForm {
|
||||
rate: String,
|
||||
enabled: Option<String>,
|
||||
}
|
||||
|
||||
fn is_checked(value: &Option<String>) -> bool {
|
||||
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let rows = currencies::Entity::find()
|
||||
.order_by_asc(currencies::Column::Code)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let currencies_json: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"code": c.code,
|
||||
"symbol": c.symbol,
|
||||
"rate": currency::format_rate(c.rate_e4),
|
||||
"enabled": c.enabled,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
format::view(
|
||||
&v,
|
||||
"admin/currencies/index.html",
|
||||
json!({
|
||||
"base_code": BASE_CODE,
|
||||
"base_symbol": BASE_SYMBOL,
|
||||
"currencies": currencies_json,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<CurrencyForm>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let row = currencies::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let mut active = row.into_active_model();
|
||||
active.rate_e4 = Set(currency::parse_rate(&form.rate)?);
|
||||
active.enabled = Set(is_checked(&form.enabled));
|
||||
active.update(&ctx.db).await?;
|
||||
format::redirect("/admin/currencies")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/currencies", get(index))
|
||||
.add("/admin/currencies/{id}", post(update))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::{product_variants, products}};
|
||||
use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}};
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Redirect,
|
||||
@@ -173,7 +173,8 @@ async fn cart_response(
|
||||
return Ok((jar, Redirect::to("/cart")).into_response());
|
||||
}
|
||||
|
||||
let (lines, valid, total) = resolve_cart(ctx, &jar).await?;
|
||||
let cur = currency::resolve(ctx, &jar).await;
|
||||
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
|
||||
// Persist the re-validated cookie (drops now-invalid lines).
|
||||
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
|
||||
let response = format::view(
|
||||
@@ -181,7 +182,8 @@ async fn cart_response(
|
||||
"shop/_cart_body.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"total": format_price(total),
|
||||
"total": cur.format(total),
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)?;
|
||||
@@ -194,6 +196,7 @@ async fn cart_response(
|
||||
pub(crate) async fn resolve_cart(
|
||||
ctx: &AppContext,
|
||||
jar: &CookieJar,
|
||||
cur: &Currency,
|
||||
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
|
||||
// Resolve the cart entries to in-stock products first, then price them all
|
||||
// for the current viewer in one batch (the price depends on who's logged in).
|
||||
@@ -226,12 +229,12 @@ pub(crate) async fn resolve_cart(
|
||||
"name": product.name,
|
||||
"variant_label": variant.label,
|
||||
"slug": product.slug,
|
||||
"price": format_price(unit_price),
|
||||
"regular_price": format_price(priced.regular_cents),
|
||||
"price": cur.format(unit_price),
|
||||
"regular_price": cur.format(priced.regular_cents),
|
||||
"on_sale": priced.is_reduced(),
|
||||
"quantity": qty,
|
||||
"stock": variant.stock,
|
||||
"line_total": format_price(line_total),
|
||||
"line_total": cur.format(line_total),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -244,7 +247,8 @@ async fn show(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
||||
let cur = currency::resolve(&ctx, &jar).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);
|
||||
@@ -254,7 +258,8 @@ async fn show(
|
||||
"shop/cart.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"total": format_price(total),
|
||||
"total": cur.format(total),
|
||||
"currency_symbol": cur.symbol,
|
||||
"logged_in_admin": c.logged_in_admin,
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
@@ -274,14 +279,16 @@ async fn preview(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
||||
let cur = currency::resolve(&ctx, &jar).await;
|
||||
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||
let rebuilt = serialize_cart(&valid);
|
||||
let response = format::view(
|
||||
&v,
|
||||
"shop/_cart_preview.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"total": format_price(total),
|
||||
"total": cur.format(total),
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
users::{self, normalize_account_type},
|
||||
},
|
||||
controllers::i18n::current_lang,
|
||||
shared::{guard, money::format_price, settings},
|
||||
shared::{currency::Currency, guard, money::format_price, settings},
|
||||
views::checkout as view,
|
||||
};
|
||||
|
||||
@@ -77,7 +77,9 @@ async fn checkout_page(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
|
||||
// Checkout and everything past it (orders, confirmation) stay in the EUR
|
||||
// base — the settlement currency — even when the buyer browsed in another.
|
||||
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||
if lines.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
@@ -159,7 +161,7 @@ async fn place_order(
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<CheckoutForm>,
|
||||
) -> Result<Response> {
|
||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
|
||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||
if valid.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
|
||||
39
src/controllers/currency.rs
Normal file
39
src/controllers/currency.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! Storefront display-currency switcher.
|
||||
//!
|
||||
//! Sets the `currency` cookie to the buyer's chosen display currency, then sends
|
||||
//! them back where they were. EUR is the base; any other code must name an
|
||||
//! enabled row in `currencies` or it falls back to EUR on the next render.
|
||||
|
||||
use axum::{
|
||||
http::{header, HeaderMap},
|
||||
response::Redirect,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::controllers::i18n::back_path;
|
||||
use crate::shared::currency::{BASE_CODE, COOKIE};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CurrencyForm {
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn set_currency(headers: HeaderMap, Form(form): Form<CurrencyForm>) -> Result<Response> {
|
||||
// Store the code uppercased; validation against the enabled set happens at
|
||||
// render time (shared::currency::resolve), which falls back to EUR.
|
||||
let code = form.currency.trim().to_uppercase();
|
||||
let code = if code.is_empty() { BASE_CODE.to_string() } else { code };
|
||||
let cookie = format!("{COOKIE}={code}; Path=/; Max-Age=31536000; SameSite=Lax");
|
||||
|
||||
Ok((
|
||||
[(header::SET_COOKIE, cookie)],
|
||||
Redirect::to(&back_path(&headers)),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().add("/currency", post(set_currency))
|
||||
}
|
||||
@@ -4,7 +4,9 @@ use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
||||
use crate::{
|
||||
controllers::i18n::current_lang, controllers::shop, shared::currency, shared::guard,
|
||||
};
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
@@ -13,7 +15,8 @@ async fn index(
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let products = shop::featured_products(&ctx, user.as_ref(), 8).await?;
|
||||
let cur = currency::resolve(&ctx, &jar).await;
|
||||
let products = shop::featured_products(&ctx, user.as_ref(), 8, &cur).await?;
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
|
||||
format::view(
|
||||
@@ -25,6 +28,7 @@ async fn index(
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Resp
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn back_path(headers: &HeaderMap) -> String {
|
||||
pub(crate) fn back_path(headers: &HeaderMap) -> String {
|
||||
let raw = headers
|
||||
.get(header::REFERER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod auth;
|
||||
pub mod auth_pages;
|
||||
pub mod oauth2;
|
||||
pub mod admin_categories;
|
||||
pub mod admin_currencies;
|
||||
pub mod admin_customers;
|
||||
pub mod admin_dashboard;
|
||||
pub mod admin_discount_profiles;
|
||||
@@ -12,6 +13,7 @@ pub mod admin_products;
|
||||
pub mod admin_shipping;
|
||||
pub mod cart;
|
||||
pub mod checkout;
|
||||
pub mod currency;
|
||||
pub mod home;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
|
||||
@@ -13,8 +13,9 @@ use serde_json::json;
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
shared::{
|
||||
currency::{self, Currency},
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
money::parse_price_to_cents,
|
||||
pricing,
|
||||
},
|
||||
models::{categories, product_images, product_variants, products, users},
|
||||
@@ -90,6 +91,7 @@ async fn run_search(
|
||||
ctx: &AppContext,
|
||||
user: Option<&users::Model>,
|
||||
params: &SearchParams,
|
||||
cur: &Currency,
|
||||
) -> Result<serde_json::Value> {
|
||||
let q = params.q.clone().unwrap_or_default();
|
||||
let q_trim = q.trim().to_string();
|
||||
@@ -136,9 +138,19 @@ async fn run_search(
|
||||
let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0);
|
||||
let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);
|
||||
|
||||
// 3. Non-category filters: price band + in-stock.
|
||||
let min_c = params.min_price.as_deref().and_then(|s| parse_price_to_cents(s).ok());
|
||||
let max_c = params.max_price.as_deref().and_then(|s| parse_price_to_cents(s).ok());
|
||||
// 3. Non-category filters: price band + in-stock. The typed bounds are in
|
||||
// the buyer's display currency; convert them back to EUR cents to compare
|
||||
// against the (EUR) resolved prices.
|
||||
let min_c = params
|
||||
.min_price
|
||||
.as_deref()
|
||||
.and_then(|s| parse_price_to_cents(s).ok())
|
||||
.map(|c| cur.to_eur_cents(c));
|
||||
let max_c = params
|
||||
.max_price
|
||||
.as_deref()
|
||||
.and_then(|s| parse_price_to_cents(s).ok())
|
||||
.map(|c| cur.to_eur_cents(c));
|
||||
let in_stock_only = is_on(¶ms.in_stock);
|
||||
items.retain(|i| {
|
||||
min_c.is_none_or(|m| i.priced.price_cents >= m)
|
||||
@@ -203,6 +215,7 @@ async fn run_search(
|
||||
item.count,
|
||||
image,
|
||||
cat_name,
|
||||
cur,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -219,8 +232,9 @@ async fn run_search(
|
||||
"in_stock": in_stock_only,
|
||||
"min_price": params.min_price.clone().unwrap_or_default(),
|
||||
"max_price": params.max_price.clone().unwrap_or_default(),
|
||||
"price_floor": format_price(price_floor),
|
||||
"price_ceil": format_price(price_ceil),
|
||||
"price_floor": cur.format(price_floor),
|
||||
"price_ceil": cur.format(price_ceil),
|
||||
"currency_symbol": cur.symbol,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
@@ -240,6 +254,7 @@ async fn product_rows(
|
||||
ctx: &AppContext,
|
||||
user: Option<&users::Model>,
|
||||
list: Vec<products::Model>,
|
||||
cur: &Currency,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||
@@ -261,7 +276,7 @@ async fn product_rows(
|
||||
let mut rows = Vec::with_capacity(entries.len());
|
||||
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
|
||||
let image = product_images::first_for(ctx, product.id).await?;
|
||||
rows.push(view::product_card(product, rep, priced, *count, image, None));
|
||||
rows.push(view::product_card(product, rep, priced, *count, image, None, cur));
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -272,6 +287,7 @@ pub(crate) async fn featured_products(
|
||||
ctx: &AppContext,
|
||||
user: Option<&users::Model>,
|
||||
limit: u64,
|
||||
cur: &Currency,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
@@ -279,7 +295,7 @@ pub(crate) async fn featured_products(
|
||||
.limit(limit)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
product_rows(ctx, user, list).await
|
||||
product_rows(ctx, user, list, cur).await
|
||||
}
|
||||
|
||||
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
|
||||
@@ -320,7 +336,8 @@ async fn index(
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default()).await?;
|
||||
let cur = currency::resolve(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default(), &cur).await?;
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
||||
format::view(&v, "shop/index.html", context)
|
||||
@@ -341,7 +358,8 @@ async fn search(
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?;
|
||||
let cur = currency::resolve(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms, &cur).await?;
|
||||
let lang = current_lang(&jar);
|
||||
|
||||
if headers.contains_key("HX-Request") {
|
||||
@@ -385,12 +403,13 @@ async fn show(
|
||||
};
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let cur = currency::resolve(&ctx, &jar).await;
|
||||
let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
|
||||
let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
|
||||
let options: Vec<serde_json::Value> = variants
|
||||
.iter()
|
||||
.zip(variant_prices.iter())
|
||||
.map(|(variant, priced)| view::variant_option(variant, priced))
|
||||
.map(|(variant, priced)| view::variant_option(variant, priced, &cur))
|
||||
.collect();
|
||||
// The card header uses the representative (first) variant for its headline
|
||||
// price; the picker below lets the customer switch.
|
||||
@@ -404,6 +423,7 @@ async fn show(
|
||||
variants.len(),
|
||||
None,
|
||||
category.as_ref().map(|c| c.name.clone()),
|
||||
&cur,
|
||||
),
|
||||
// A product with no variants isn't purchasable; show it without a price.
|
||||
_ => serde_json::json!({
|
||||
@@ -428,6 +448,7 @@ async fn show(
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
@@ -463,7 +484,8 @@ async fn category(
|
||||
};
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?;
|
||||
let cur = currency::resolve(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms, &cur).await?;
|
||||
if let Some(map) = context.as_object_mut() {
|
||||
map.insert("category".into(), serde_json::to_value(&category)?);
|
||||
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);
|
||||
|
||||
50
src/initializers/currency_seeder.rs
Normal file
50
src/initializers/currency_seeder.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Ensures the built-in alternative display currencies always exist.
|
||||
//!
|
||||
//! EUR is the base currency and is never stored. For now the only alternative is
|
||||
//! the Czech koruna (CZK); the admin sets its exchange rate and can disable it.
|
||||
//! We insert each one only when its `code` is missing, so an admin's rate/enabled
|
||||
//! changes are never overwritten on the next boot.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
|
||||
use crate::models::currencies;
|
||||
use crate::shared::currency::SCALE;
|
||||
|
||||
/// `(code, symbol, default_rate_e4)` — default rate is a placeholder the admin
|
||||
/// is expected to update from the live FX rate.
|
||||
const BUILTINS: [(&str, &str, i64); 1] = [("CZK", "Kč", 25 * SCALE)];
|
||||
|
||||
pub struct CurrencySeeder;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for CurrencySeeder {
|
||||
fn name(&self) -> String {
|
||||
"currency-seeder".to_string()
|
||||
}
|
||||
|
||||
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||
for (code, symbol, rate_e4) in BUILTINS {
|
||||
let exists = currencies::Entity::find()
|
||||
.filter(currencies::Column::Code.eq(code))
|
||||
.count(&ctx.db)
|
||||
.await?
|
||||
> 0;
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
currencies::ActiveModel {
|
||||
code: Set(code.to_string()),
|
||||
symbol: Set(symbol.to_string()),
|
||||
rate_e4: Set(rate_e4),
|
||||
enabled: Set(true),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
tracing::info!(currency = code, "seeded display currency");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod admin_seeder;
|
||||
pub mod currency_seeder;
|
||||
pub mod oauth2;
|
||||
pub mod oauth2_session;
|
||||
pub mod shipping_seeder;
|
||||
|
||||
22
src/models/_entities/currencies.rs
Normal file
22
src/models/_entities/currencies.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! `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 = "currencies")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(unique)]
|
||||
pub code: String,
|
||||
pub symbol: String,
|
||||
/// Units of this currency per 1 EUR, scaled ×10000 (e.g. 25.30 → 253000).
|
||||
pub rate_e4: i64,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
@@ -8,6 +8,7 @@ pub mod account_product_resolutions;
|
||||
pub mod audience_discount_profiles;
|
||||
pub mod audit_logs;
|
||||
pub mod categories;
|
||||
pub mod currencies;
|
||||
pub mod customer_profiles;
|
||||
pub mod discount_profile_products;
|
||||
pub mod discount_profiles;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
||||
pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles;
|
||||
pub use super::audit_logs::Entity as AuditLogs;
|
||||
pub use super::categories::Entity as Categories;
|
||||
pub use super::currencies::Entity as Currencies;
|
||||
pub use super::customer_profiles::Entity as CustomerProfiles;
|
||||
pub use super::discount_profile_products::Entity as DiscountProfileProducts;
|
||||
pub use super::discount_profiles::Entity as DiscountProfiles;
|
||||
|
||||
40
src/models/currencies.rs
Normal file
40
src/models/currencies.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use crate::models::_entities::currencies::{ActiveModel, Column, Entity, Model};
|
||||
pub type Currencies = 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,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {
|
||||
/// An enabled currency by its ISO code (case-insensitive), or `None`.
|
||||
pub async fn find_enabled_by_code<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
code: &str,
|
||||
) -> Result<Option<Model>, DbErr> {
|
||||
Entity::find()
|
||||
.filter(Column::Code.eq(code.to_uppercase()))
|
||||
.filter(Column::Enabled.eq(true))
|
||||
.one(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod account_product_resolutions;
|
||||
pub mod audience_discount_profiles;
|
||||
pub mod audit_logs;
|
||||
pub mod categories;
|
||||
pub mod currencies;
|
||||
pub mod discount_profile_products;
|
||||
pub mod discount_profiles;
|
||||
pub mod customer_profiles;
|
||||
|
||||
144
src/shared/currency.rs
Normal file
144
src/shared/currency.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Buyer-selectable display currency.
|
||||
//!
|
||||
//! EUR is the base/transaction currency: every price is stored and reasoned
|
||||
//! about in EUR minor units (cents). A buyer may switch their *display* currency
|
||||
//! (cookie [`COOKIE`]); non-base currencies live in the `currencies` table with
|
||||
//! an admin-set exchange `rate_e4` (units per 1 EUR, scaled ×10000). The
|
||||
//! [`Currency`] resolved per request converts EUR cents into the chosen currency
|
||||
//! for display only — the cart logic, orders and admin stay in EUR.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::models::currencies;
|
||||
use crate::shared::money::format_price;
|
||||
|
||||
/// Cookie holding the buyer's chosen display-currency code.
|
||||
pub const COOKIE: &str = "currency";
|
||||
/// The base/transaction currency code.
|
||||
pub const BASE_CODE: &str = "EUR";
|
||||
/// The base currency symbol.
|
||||
pub const BASE_SYMBOL: &str = "€";
|
||||
/// Fixed-point scale for exchange rates (`rate_e4` = rate × 10000).
|
||||
pub const SCALE: i64 = 10_000;
|
||||
|
||||
/// A resolved display currency: how to label prices and how to convert them
|
||||
/// from the EUR base.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Currency {
|
||||
pub code: String,
|
||||
pub symbol: String,
|
||||
/// Units of this currency per 1 EUR, scaled ×10000. `SCALE` for the base.
|
||||
pub rate_e4: i64,
|
||||
}
|
||||
|
||||
impl Currency {
|
||||
/// The base currency (EUR), the identity conversion.
|
||||
#[must_use]
|
||||
pub fn eur() -> Self {
|
||||
Self {
|
||||
code: BASE_CODE.to_string(),
|
||||
symbol: BASE_SYMBOL.to_string(),
|
||||
rate_e4: SCALE,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_base(&self) -> bool {
|
||||
self.code == BASE_CODE
|
||||
}
|
||||
|
||||
/// Convert EUR minor units into this currency's minor units (half-up).
|
||||
#[must_use]
|
||||
pub fn convert_cents(&self, eur_cents: i64) -> i64 {
|
||||
if self.is_base() {
|
||||
return eur_cents;
|
||||
}
|
||||
let scale = i128::from(SCALE);
|
||||
((i128::from(eur_cents) * i128::from(self.rate_e4) + scale / 2) / scale) as i64
|
||||
}
|
||||
|
||||
/// Inverse of [`convert_cents`]: this currency's minor units back to EUR
|
||||
/// minor units (half-up). Used to interpret price-filter bounds typed in the
|
||||
/// display currency.
|
||||
#[must_use]
|
||||
pub fn to_eur_cents(&self, cents: i64) -> i64 {
|
||||
if self.is_base() || self.rate_e4 == 0 {
|
||||
return cents;
|
||||
}
|
||||
let rate = i128::from(self.rate_e4);
|
||||
((i128::from(cents) * i128::from(SCALE) + rate / 2) / rate) as i64
|
||||
}
|
||||
|
||||
/// Render EUR minor units as a plain decimal string in this currency (no
|
||||
/// symbol). The symbol is appended by templates via `currency_symbol`.
|
||||
#[must_use]
|
||||
pub fn format(&self, eur_cents: i64) -> String {
|
||||
format_price(self.convert_cents(eur_cents))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the buyer's display currency from the `currency` cookie, falling back
|
||||
/// to EUR when the cookie is absent, names the base, or names a currency that is
|
||||
/// missing or disabled.
|
||||
pub async fn resolve(ctx: &AppContext, jar: &CookieJar) -> Currency {
|
||||
let code = jar
|
||||
.get(COOKIE)
|
||||
.map(|c| c.value().to_string())
|
||||
.unwrap_or_default();
|
||||
if code.is_empty() || code.eq_ignore_ascii_case(BASE_CODE) {
|
||||
return Currency::eur();
|
||||
}
|
||||
match currencies::Entity::find_enabled_by_code(&ctx.db, &code).await {
|
||||
Ok(Some(m)) => Currency {
|
||||
code: m.code,
|
||||
symbol: m.symbol,
|
||||
rate_e4: m.rate_e4,
|
||||
},
|
||||
_ => Currency::eur(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an exchange rate typed in major units ("25", "25.3", "25,30",
|
||||
/// "25.3045") into `rate_e4` (×10000). Rejects negatives and >4 decimals.
|
||||
pub fn parse_rate(value: &str) -> Result<i64> {
|
||||
let value = value.trim().replace(',', ".");
|
||||
let invalid = || Error::BadRequest("invalid exchange rate".to_string());
|
||||
let (whole, frac) = match value.split_once('.') {
|
||||
Some((w, f)) => (w, f),
|
||||
None => (value.as_str(), ""),
|
||||
};
|
||||
if frac.len() > 4 || whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if !frac.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let whole: i64 = whole.parse().map_err(|_| invalid())?;
|
||||
// Right-pad the fractional part to exactly 4 digits.
|
||||
let padded = format!("{frac:0<4}");
|
||||
let frac: i64 = if padded.is_empty() {
|
||||
0
|
||||
} else {
|
||||
padded.parse().map_err(|_| invalid())?
|
||||
};
|
||||
let rate = whole * SCALE + frac;
|
||||
if rate <= 0 {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(rate)
|
||||
}
|
||||
|
||||
/// Render `rate_e4` as a human string, trimming trailing zeros (253000 → "25.3",
|
||||
/// 250000 → "25").
|
||||
#[must_use]
|
||||
pub fn format_rate(rate_e4: i64) -> String {
|
||||
let whole = rate_e4 / SCALE;
|
||||
let frac = (rate_e4 % SCALE).abs();
|
||||
if frac == 0 {
|
||||
return whole.to_string();
|
||||
}
|
||||
let frac = format!("{frac:04}");
|
||||
let trimmed = frac.trim_end_matches('0');
|
||||
format!("{whole}.{trimmed}")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Cross-cutting helpers used across feature slices.
|
||||
|
||||
pub mod csrf;
|
||||
pub mod currency;
|
||||
pub mod guard;
|
||||
pub mod money;
|
||||
pub mod pricing;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::models::_entities::{categories, product_images, product_variants, products};
|
||||
use crate::shared::money::format_price;
|
||||
use crate::shared::currency::Currency;
|
||||
use crate::shared::pricing::PricedProduct;
|
||||
|
||||
/// Card/list shape for a product: model fields plus the viewer's resolved price
|
||||
@@ -20,6 +20,7 @@ pub fn product_card(
|
||||
variant_count: usize,
|
||||
image: Option<String>,
|
||||
category_name: Option<String>,
|
||||
cur: &Currency,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
@@ -28,10 +29,10 @@ pub fn product_card(
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"short_description": product.short_description,
|
||||
"price": format_price(priced.price_cents),
|
||||
"price": cur.format(priced.price_cents),
|
||||
"on_sale": priced.is_reduced(),
|
||||
"is_business": priced.is_business,
|
||||
"regular_price": format_price(priced.regular_cents),
|
||||
"regular_price": cur.format(priced.regular_cents),
|
||||
"sku": representative.sku,
|
||||
"stock": representative.stock,
|
||||
"tracked": representative.tracked(),
|
||||
@@ -45,7 +46,11 @@ pub fn product_card(
|
||||
}
|
||||
|
||||
/// One priced variant row for the product detail page's option picker.
|
||||
pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct) -> Value {
|
||||
pub fn variant_option(
|
||||
variant: &product_variants::Model,
|
||||
priced: &PricedProduct,
|
||||
cur: &Currency,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": variant.id,
|
||||
"label": variant.label,
|
||||
@@ -53,9 +58,9 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
|
||||
"stock": variant.stock,
|
||||
"tracked": variant.tracked(),
|
||||
"in_stock": variant.in_stock(),
|
||||
"price": format_price(priced.price_cents),
|
||||
"price": cur.format(priced.price_cents),
|
||||
"on_sale": priced.is_reduced(),
|
||||
"regular_price": format_price(priced.regular_cents),
|
||||
"regular_price": cur.format(priced.regular_cents),
|
||||
"is_business": priced.is_business,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user