eur czk can be disabled from now on
Some checks are pending
CI / Check Style (push) Waiting to run
CI / Run Clippy (push) Waiting to run
CI / Run Tests (push) Waiting to run

This commit is contained in:
Priec
2026-06-23 21:54:09 +02:00
parent c409e85995
commit ac31cdfbf3
8 changed files with 105 additions and 15 deletions

View File

@@ -476,8 +476,9 @@ 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 = Exchange rate
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
currency-rate = Rate
exchange-rate = Exchange rate
exchange-rate-hint = { $code } prices are the { $base } price recalculated at this rate.
currency-enabled = Available to customers

View File

@@ -476,8 +476,9 @@ 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 = Kurz
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.
currency-rate = Kurz
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

View File

@@ -104,8 +104,19 @@
{% endif %}
</ul>
<!-- right side: cart + settings + mobile toggle -->
<!-- right side: kurz + cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-3">
<!-- exchange-rate ("kurz") display: the admin-set EUR→alt rate(s).
Hidden when the store is EUR-only (no enabled alternatives). -->
{% set nav_cc = currencies() %}
{% if nav_cc.alts | length > 0 %}
<div class="hidden items-center gap-2 text-xs text-on-surface/70 dark:text-on-surface-dark/70 sm:flex">
<span class="font-semibold uppercase tracking-wide">{{ t(key="currency-rate", lang=lang | default(value='sk')) }}</span>
{% for a in nav_cc.alts %}
<span class="tabular-nums">1 {{ nav_cc.base.symbol }} = {{ a.rate }} {{ a.symbol }}</span>
{% endfor %}
</div>
{% endif %}
<!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %}
{% include "partials/profile_menu.html" %}

View File

@@ -35,27 +35,32 @@
{% 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. #}
{# Currency switcher. Only enabled (buyer-available) currencies are listed,
from the `currencies()` snapshot; the whole section is hidden when the store
is EUR-only (no enabled alternatives). The active code is read from the
`currency` cookie client-side (Alpine); posting to /currency sets it. #}
{% set cc = currencies() %}
{% if cc.alts | length > 0 %}
<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]) }">
x-data="{ cur: ((document.cookie.split('; ').find(function (c) { return c.indexOf('currency=') === 0 }) || 'currency={{ cc.base.code }}').split('=')[1]) }">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button type="submit" name="currency" value="EUR" role="menuitem"
<button type="submit" name="currency" value="{{ cc.base.code }}" 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>
<span>{{ cc.base.code }} ({{ cc.base.symbol }})</span>
<span x-cloak x-show="cur === '{{ cc.base.code }}'" class="text-primary dark:text-primary-dark"></span>
</button>
<button type="submit" name="currency" value="CZK" role="menuitem"
{% for a in cc.alts %}
<button type="submit" name="currency" value="{{ a.code }}" 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>
<span>{{ a.code }} ({{ a.symbol }})</span>
<span x-cloak x-show="cur === '{{ a.code }}'" class="text-primary dark:text-primary-dark"></span>
</button>
{% endfor %}
</form>
{% endif %}
<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>

View File

@@ -82,6 +82,8 @@ async fn update(
active.rate_e4 = Set(currency::parse_rate(&form.rate)?);
active.enabled = Set(is_checked(&form.enabled));
active.update(&ctx.db).await?;
// Keep the navbar/settings chrome snapshot in sync with the new rate/state.
currency::refresh_snapshot(&ctx.db).await?;
format::redirect("/admin/currencies")
}

View File

@@ -10,7 +10,7 @@ use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use crate::models::currencies;
use crate::shared::currency::SCALE;
use crate::shared::currency::{self, SCALE};
/// `(code, symbol, default_rate_e4)` — default rate is a placeholder the admin
/// is expected to update from the live FX rate.
@@ -45,6 +45,8 @@ impl Initializer for CurrencySeeder {
.await?;
tracing::info!(currency = code, "seeded display currency");
}
// Prime the process-wide snapshot used by the navbar/settings chrome.
currency::refresh_snapshot(&ctx.db).await?;
Ok(())
}
}

View File

@@ -54,6 +54,12 @@ impl Initializer for ViewEngineInitializer {
crate::shared::csrf::current_token().unwrap_or_default(),
))
});
// `currencies()`: the EUR base plus enabled alternative currencies
// (from the process-wide snapshot), used by the global chrome — the
// settings-menu switcher and the navbar exchange-rate display.
tera.register_function("currencies", |_args: &HashMap<String, serde_json::Value>| {
Ok(crate::shared::currency::selectable_json())
});
Ok(())
})?;

View File

@@ -7,8 +7,11 @@
//! [`Currency`] resolved per request converts EUR cents into the chosen currency
//! for display only — the cart logic, orders and admin stay in EUR.
use std::sync::RwLock;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use crate::models::currencies;
use crate::shared::money::format_price;
@@ -99,6 +102,65 @@ pub async fn resolve(ctx: &AppContext, jar: &CookieJar) -> Currency {
}
}
/// One enabled, buyer-selectable alternative currency in the process-wide
/// snapshot below.
#[derive(Clone)]
struct Selectable {
code: String,
symbol: String,
rate_e4: i64,
}
/// Process-wide snapshot of the enabled alternative currencies, so the global
/// chrome (the settings-menu switcher and the navbar rate) can be rendered via a
/// Tera function without a per-request DB hit. Loaded at boot by
/// `initializers::currency_seeder` and refreshed by the admin on every edit (see
/// [`refresh_snapshot`]). EUR (the base) is implicit and never listed here.
static ENABLED: RwLock<Vec<Selectable>> = RwLock::new(Vec::new());
/// Reload the [`ENABLED`] snapshot from the database. Call at boot and after any
/// admin change to a currency's rate/enabled state.
pub async fn refresh_snapshot<C: sea_orm::ConnectionTrait>(db: &C) -> Result<()> {
let rows = currencies::Entity::find()
.filter(currencies::Column::Enabled.eq(true))
.order_by_asc(currencies::Column::Code)
.all(db)
.await?;
let list = rows
.into_iter()
.map(|m| Selectable {
code: m.code,
symbol: m.symbol,
rate_e4: m.rate_e4,
})
.collect();
*ENABLED.write().unwrap() = list;
Ok(())
}
/// The selectable currencies for templates (the Tera `currencies()` function):
/// the EUR base plus every enabled alternative, each with a human rate string.
/// `alts` is empty when the store is effectively EUR-only.
#[must_use]
pub fn selectable_json() -> serde_json::Value {
let alts: Vec<serde_json::Value> = ENABLED
.read()
.unwrap()
.iter()
.map(|s| {
serde_json::json!({
"code": s.code,
"symbol": s.symbol,
"rate": format_rate(s.rate_e4),
})
})
.collect();
serde_json::json!({
"base": { "code": BASE_CODE, "symbol": BASE_SYMBOL },
"alts": alts,
})
}
/// 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> {