Compare commits
3 Commits
baf7522273
...
9ce07e8c23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce07e8c23 | ||
|
|
b255e95051 | ||
|
|
f0a6f97609 |
@@ -198,6 +198,8 @@ image = Image
|
||||
slug = URL slug
|
||||
slug-auto = generated automatically
|
||||
position = Position
|
||||
parent-category = Parent category
|
||||
no-parent = — None (top level) —
|
||||
quantity = Quantity
|
||||
add-to-cart = Add to cart
|
||||
in-stock = In stock
|
||||
@@ -206,6 +208,8 @@ confirm-delete = Delete this for good?
|
||||
shop-title = Shop
|
||||
shop-subtitle = browse our products.
|
||||
shop-empty = There are no products here yet.
|
||||
categories = Categories
|
||||
all-products = All products
|
||||
cart-title = Cart
|
||||
cart-empty = Your cart is empty.
|
||||
cart-total = Total
|
||||
@@ -239,3 +243,23 @@ order-status-paid = Paid
|
||||
order-status-shipped = Shipped
|
||||
order-status-cancelled = Cancelled
|
||||
order-update-status = Update status
|
||||
|
||||
# --- eshop: shipping & payment ---
|
||||
checkout-carrier = Delivery
|
||||
checkout-payment = Payment method
|
||||
checkout-subtotal = Subtotal
|
||||
checkout-shipping-cost = Shipping
|
||||
checkout-pick-point = Choose pickup point
|
||||
checkout-chosen-point = Chosen point
|
||||
checkout-pickup-point = Pickup point
|
||||
payment-cod = Cash on delivery
|
||||
payment-bank = Bank transfer
|
||||
payment-bank-instructions = Please transfer the amount to our account:
|
||||
payment-cod-note = You will pay for the goods on delivery.
|
||||
payment-bank-note = We will ship once the payment arrives.
|
||||
bank-account-name = Account holder
|
||||
bank-variable-symbol = Variable symbol
|
||||
bank-amount = Amount
|
||||
admin-shipping = Shipping
|
||||
admin-shipping-desc = set carrier prices and availability.
|
||||
shipping-enabled = Active
|
||||
|
||||
@@ -198,6 +198,8 @@ image = Obrázok
|
||||
slug = URL adresa
|
||||
slug-auto = vygeneruje sa automaticky
|
||||
position = Poradie
|
||||
parent-category = Nadradená kategória
|
||||
no-parent = — Žiadna (najvyššia úroveň) —
|
||||
quantity = Množstvo
|
||||
add-to-cart = Pridať do košíka
|
||||
in-stock = Na sklade
|
||||
@@ -206,6 +208,8 @@ confirm-delete = Naozaj zmazať?
|
||||
shop-title = Obchod
|
||||
shop-subtitle = prezrite si našu ponuku produktov.
|
||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||
categories = Kategórie
|
||||
all-products = Všetky produkty
|
||||
cart-title = Košík
|
||||
cart-empty = Váš košík je prázdny.
|
||||
cart-total = Spolu
|
||||
@@ -239,3 +243,23 @@ order-status-paid = Zaplatené
|
||||
order-status-shipped = Odoslané
|
||||
order-status-cancelled = Zrušené
|
||||
order-update-status = Zmeniť stav
|
||||
|
||||
# --- eshop: shipping & payment ---
|
||||
checkout-carrier = Doprava
|
||||
checkout-payment = Spôsob platby
|
||||
checkout-subtotal = Medzisúčet
|
||||
checkout-shipping-cost = Doprava
|
||||
checkout-pick-point = Vybrať výdajné miesto
|
||||
checkout-chosen-point = Vybrané miesto
|
||||
checkout-pickup-point = Výdajné miesto
|
||||
payment-cod = Dobierka (platba pri prevzatí)
|
||||
payment-bank = Bankový prevod
|
||||
payment-bank-instructions = Sumu uhraďte prevodom na náš účet:
|
||||
payment-cod-note = Za tovar zaplatíte pri jeho prevzatí.
|
||||
payment-bank-note = Po prijatí platby objednávku odošleme.
|
||||
bank-account-name = Príjemca
|
||||
bank-variable-symbol = Variabilný symbol
|
||||
bank-amount = Suma
|
||||
admin-shipping = Doprava
|
||||
admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov.
|
||||
shipping-enabled = Aktívne
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -79,6 +79,10 @@
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/shipping" data-nav="/admin/shipping"
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||
|
||||
@@ -29,7 +29,12 @@
|
||||
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||
{% for row in categories %}
|
||||
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
|
||||
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ row.category.name }}</td>
|
||||
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
<span style="padding-left: {{ row.depth * 20 }}px" class="inline-flex items-center gap-1.5">
|
||||
{% if row.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||
{{ row.category.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 tabular-nums">{{ row.product_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.category.published %}
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
|
||||
<select id="parent_id" name="parent_id"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||
{% for parent in parents %}
|
||||
<option value="{{ parent.id }}" {% if editing and category.parent_id == parent.id %}selected{% endif %}>
|
||||
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||
<textarea id="description" name="description" rows="4"
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}</p>
|
||||
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</p>
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
|
||||
{% if order.payment_method == "bank_transfer" %}{{ t(key="payment-bank", lang=lang | default(value='sk')) }} · VS {{ order.variable_symbol }}{% else %}{{ t(key="payment-cod", lang=lang | default(value='sk')) }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if order.note %}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</p>
|
||||
|
||||
37
assets/views/admin/shipping/index.html
Normal file
37
assets/views/admin/shipping/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-shipping", 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-shipping", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-shipping-desc", lang=lang | default(value='sk')) }}</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
{% for method in methods %}
|
||||
<form method="post" action="/admin/shipping/{{ method.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">
|
||||
<div class="min-w-40">
|
||||
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
|
||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
|
||||
class="w-28 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" name="enabled" value="on" {% if method.enabled %}checked{% endif %}
|
||||
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shipping-enabled", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
<button type="submit"
|
||||
class="ml-auto inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -45,10 +45,20 @@
|
||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||
</head>
|
||||
<body hx-boost="true"
|
||||
class="flex min-h-screen flex-col bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
||||
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
|
||||
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<header
|
||||
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
|
||||
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
|
||||
<!-- category sidebar toggle (mobile only) -->
|
||||
<button type="button" @click="cats = !cats" :aria-expanded="cats"
|
||||
aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt lg:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/"
|
||||
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||
@@ -169,8 +179,23 @@
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
<!-- dark overlay behind the category drawer on small screens -->
|
||||
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
|
||||
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
||||
<!-- persistent category sidebar (off-canvas drawer on mobile).
|
||||
hx-preserve keeps this node across boosted page swaps, so it is
|
||||
fetched once (hx-trigger=load) and never reloaded on navigation. -->
|
||||
<aside id="category-sidebar" hx-preserve="true"
|
||||
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||
hx-get="/partials/categories" hx-trigger="load"
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
</aside>
|
||||
|
||||
<main class="min-w-0 flex-1">
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
27
assets/views/shop/_sidebar.html
Normal file
27
assets/views/shop/_sidebar.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{# Site-wide category sidebar contents, served as an htmx partial and swapped
|
||||
into the <aside> in base.html. `category_tree` is a depth-ordered flat list
|
||||
of { name, slug, depth }; nesting is shown via left indentation. Active state
|
||||
is set client-side by markActiveNav() via data-nav + aria-current. #}
|
||||
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li>
|
||||
<a href="/shop" data-nav="/shop"
|
||||
class="block rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
</li>
|
||||
{% for item in category_tree %}
|
||||
<li>
|
||||
<a href="/category/{{ item.slug }}" data-nav="/category/{{ item.slug }}" style="padding-left: {{ 12 + item.depth * 16 }}px"
|
||||
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{% if item.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if category_tree | length == 0 %}
|
||||
<p class="px-3 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||
{% endif %}
|
||||
@@ -7,15 +7,28 @@
|
||||
<header class="space-y-2">
|
||||
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
||||
{% for crumb in breadcrumbs %}
|
||||
<span class="px-1">/</span>
|
||||
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
|
||||
{% endfor %}
|
||||
<span class="px-1">/</span>
|
||||
<span>{{ category.name }}</span>
|
||||
</nav>
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
|
||||
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
|
||||
|
||||
{% if children | length > 0 %}
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
{% for child in children %}
|
||||
<a href="/category/{{ child.slug }}"
|
||||
class="rounded-full border border-outline px-3 py-1 text-sm font-medium text-on-surface transition hover:border-primary hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:border-primary-dark dark:hover:text-primary-dark">{{ child.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if products | length > 0 %}
|
||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{% for product in products %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -3,10 +3,34 @@
|
||||
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
{% if packeta_api_key %}<script src="https://widget.packeta.com/v6/www/js/library.js"></script>{% endif %}
|
||||
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-title", lang=lang | default(value='sk')) }}</h1>
|
||||
|
||||
<div class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||
<form method="post" action="/checkout" hx-boost="false" class="space-y-6 lg:col-span-2">
|
||||
<form method="post" action="/checkout" hx-boost="false"
|
||||
x-data="{
|
||||
paymentMethod: '',
|
||||
carrier: '',
|
||||
carrierPrice: 0,
|
||||
requiresPoint: false,
|
||||
pointId: '',
|
||||
pointName: '',
|
||||
subtotal: {{ subtotal_cents }},
|
||||
packetaKey: '{{ packeta_api_key }}',
|
||||
fmt(c) { return (c / 100).toFixed(2) },
|
||||
pickPoint() {
|
||||
Packeta.Widget.pick(this.packetaKey, (point) => {
|
||||
if (point) { this.pointId = String(point.id); this.pointName = point.formatedValue || point.name }
|
||||
})
|
||||
},
|
||||
get canSubmit() {
|
||||
return this.paymentMethod && this.carrier && (!this.requiresPoint || this.pointId)
|
||||
}
|
||||
}"
|
||||
class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<!-- contact -->
|
||||
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
@@ -21,6 +45,7 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- shipping address -->
|
||||
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
@@ -41,23 +66,70 @@
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="country" name="country" type="text" required
|
||||
<input id="country" name="country" type="text" required value="Slovensko"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
|
||||
<textarea id="note" name="note" rows="3"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"></textarea>
|
||||
</fieldset>
|
||||
|
||||
<!-- carrier -->
|
||||
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</legend>
|
||||
{% for m in shipping_methods %}
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||
<span class="flex items-center gap-3">
|
||||
<input type="radio" name="carrier_code" value="{{ m.code }}" required
|
||||
@change="carrier='{{ m.code }}'; carrierPrice={{ m.price_cents }}; requiresPoint={{ m.requires_pickup_point }}; pointId=''; pointName=''"
|
||||
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
|
||||
</span>
|
||||
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
<!-- pickup point (carriers that need one, e.g. Packeta) -->
|
||||
<div x-show="requiresPoint" x-cloak class="space-y-2 pt-1">
|
||||
<input type="hidden" name="pickup_point_id" x-model="pointId">
|
||||
<input type="hidden" name="pickup_point_name" x-model="pointName">
|
||||
{% if packeta_api_key %}
|
||||
<button type="button" @click="pickPoint()"
|
||||
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||
{{ t(key="checkout-pick-point", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
<p x-show="pointName" x-cloak class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">
|
||||
<span class="font-medium">{{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}:</span> <span x-text="pointName"></span>
|
||||
</p>
|
||||
{% else %}
|
||||
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}</label>
|
||||
<input type="text" x-model="pointName" @input="pointId = pointName"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
{% endif %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</form>
|
||||
<!-- payment -->
|
||||
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</legend>
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||
<input type="radio" name="payment_method" value="cod" required x-model="paymentMethod"
|
||||
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||
<input type="radio" name="payment_method" value="bank_transfer" required x-model="paymentMethod"
|
||||
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
|
||||
<textarea id="note" name="note" rows="3"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<aside class="h-fit space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<h2 class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-summary", lang=lang | default(value='sk')) }}</h2>
|
||||
<ul class="space-y-2 text-sm">
|
||||
@@ -68,10 +140,24 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
|
||||
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</span>
|
||||
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
|
||||
</div>
|
||||
<button type="submit" :disabled="!canSubmit"
|
||||
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -9,17 +9,8 @@
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||
</header>
|
||||
|
||||
{% if categories | length > 0 %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for category in categories %}
|
||||
<a href="/category/{{ category.slug }}"
|
||||
class="rounded-full border border-outline px-3 py-1 text-sm font-medium text-on-surface transition hover:border-primary hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:border-primary-dark dark:hover:text-primary-dark">{{ category.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if products | length > 0 %}
|
||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{% for product in products %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl space-y-6 text-center">
|
||||
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}</h1>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="mt-1 text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-radius border border-outline bg-surface p-6 text-left dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<div class="flex flex-wrap justify-between gap-2 border-b border-outline pb-3 dark:border-outline-dark">
|
||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-number", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="font-mono font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</span>
|
||||
@@ -27,12 +27,35 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
||||
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
|
||||
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
||||
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/shop" class="inline-flex items-center justify-center rounded-radius border border-outline px-5 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||
{% if order.payment_method == "bank_transfer" %}
|
||||
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
|
||||
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-radius border border-outline bg-surface p-4 text-sm text-on-surface/80 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/80">
|
||||
{{ t(key="payment-cod-note", lang=lang | default(value='sk')) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/shop" class="inline-flex items-center justify-center rounded-radius border border-outline px-5 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -105,3 +105,9 @@ auth:
|
||||
settings:
|
||||
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
|
||||
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }}
|
||||
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
|
||||
# Empty falls back to a plain text field for the pickup point.
|
||||
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
||||
# Bank-transfer payment details shown on the order confirmation.
|
||||
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
|
||||
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}
|
||||
|
||||
@@ -24,6 +24,9 @@ mod m20260616_130610_orders;
|
||||
mod m20260616_130628_order_items;
|
||||
mod m20260616_131000_drop_audio_tables;
|
||||
mod m20260616_132000_drop_blog_and_pages;
|
||||
mod m20260616_150755_shipping_methods;
|
||||
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||
mod m20260616_160000_add_parent_to_categories;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -52,6 +55,9 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260616_130628_order_items::Migration),
|
||||
Box::new(m20260616_131000_drop_audio_tables::Migration),
|
||||
Box::new(m20260616_132000_drop_blog_and_pages::Migration),
|
||||
Box::new(m20260616_150755_shipping_methods::Migration),
|
||||
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
||||
Box::new(m20260616_160000_add_parent_to_categories::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
30
migration/src/m20260616_150755_shipping_methods.rs
Normal file
30
migration/src/m20260616_150755_shipping_methods.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
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, "shipping_methods",
|
||||
&[
|
||||
|
||||
("id", ColType::PkAuto),
|
||||
|
||||
("code", ColType::StringUniq),
|
||||
("name", ColType::String),
|
||||
("price_cents", ColType::BigIntegerWithDefault(0)),
|
||||
("requires_pickup_point", ColType::BooleanWithDefault(false)),
|
||||
("enabled", ColType::BooleanWithDefault(true)),
|
||||
("position", ColType::IntegerWithDefault(0)),
|
||||
],
|
||||
&[
|
||||
]
|
||||
).await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
drop_table(m, "shipping_methods").await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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> {
|
||||
add_column(m, "orders", "payment_method", ColType::StringNull).await?;
|
||||
add_column(m, "orders", "carrier_code", ColType::StringNull).await?;
|
||||
add_column(m, "orders", "carrier_name", ColType::StringNull).await?;
|
||||
add_column(m, "orders", "shipping_cents", ColType::BigIntegerWithDefault(0)).await?;
|
||||
add_column(m, "orders", "pickup_point_id", ColType::StringNull).await?;
|
||||
add_column(m, "orders", "pickup_point_name", ColType::StringNull).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
remove_column(m, "orders", "payment_method").await?;
|
||||
remove_column(m, "orders", "carrier_code").await?;
|
||||
remove_column(m, "orders", "carrier_name").await?;
|
||||
remove_column(m, "orders", "shipping_cents").await?;
|
||||
remove_column(m, "orders", "pickup_point_id").await?;
|
||||
remove_column(m, "orders", "pickup_point_name").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Categories {
|
||||
Table,
|
||||
Id,
|
||||
ParentId,
|
||||
}
|
||||
|
||||
const FK_NAME: &str = "fk_categories_parent_id";
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.alter_table(
|
||||
Table::alter()
|
||||
.table(Categories::Table)
|
||||
.add_column(ColumnDef::new(Categories::ParentId).integer().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
m.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name(FK_NAME)
|
||||
.from(Categories::Table, Categories::ParentId)
|
||||
.to(Categories::Table, Categories::Id)
|
||||
.on_delete(ForeignKeyAction::SetNull)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name(FK_NAME)
|
||||
.table(Categories::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
m.alter_table(
|
||||
Table::alter()
|
||||
.table(Categories::Table)
|
||||
.drop_column(Categories::ParentId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
account::models::users::{self, LoginParams, RegisterParams},
|
||||
account::view::{CurrentResponse, LoginResponse},
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
shared::guard::is_admin,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
@@ -22,18 +23,6 @@ fn get_allow_email_domain_re() -> &'static Regex {
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
|
||||
Cookie::build((AUTH_COOKIE, token.to_string()))
|
||||
.path("/")
|
||||
1
src/account/models/mod.rs
Normal file
1
src/account/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod users;
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Map;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
|
||||
pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model};
|
||||
|
||||
pub const MAGIC_LINK_LENGTH: i8 = 32;
|
||||
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
|
||||
@@ -41,7 +41,7 @@ impl Validatable for ActiveModel {
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
|
||||
impl ActiveModelBehavior for crate::models::_entities::users::ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
269
src/admin/categories.rs
Normal file
269
src/admin/categories.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Admin category CRUD, including parent/child hierarchy management.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
shared::{
|
||||
guard,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
shop::models::{categories, products},
|
||||
};
|
||||
|
||||
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
categories::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
/// Fields parsed from a category form.
|
||||
struct CategoryFields {
|
||||
name: String,
|
||||
slug: String,
|
||||
description: Option<String>,
|
||||
position: i32,
|
||||
published: bool,
|
||||
parent_id: Option<i32>,
|
||||
}
|
||||
|
||||
async fn parse_category_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<CategoryFields> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||
let description = form.text("description");
|
||||
let position = form
|
||||
.text("position")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let published = form.checked("published");
|
||||
|
||||
// Resolve the chosen parent, rejecting cycles: a category may not be its
|
||||
// own parent nor be re-parented under one of its descendants.
|
||||
let parent_id = match form.text("parent_id").and_then(|s| s.parse::<i32>().ok()) {
|
||||
Some(parent_id) => {
|
||||
categories::Entity::find_by_id(parent_id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?;
|
||||
if let Some(id) = current_id {
|
||||
if parent_id == id {
|
||||
return Err(Error::BadRequest(
|
||||
"a category cannot be its own parent".to_string(),
|
||||
));
|
||||
}
|
||||
if categories::descendant_ids(&categories::all(ctx).await?, id).contains(&parent_id)
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"a category cannot be moved under its own descendant".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(parent_id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let desired = form
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| slugify(&name));
|
||||
let slug = unique_slug(&desired, |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
let mut query =
|
||||
categories::Entity::find().filter(categories::Column::Slug.eq(candidate));
|
||||
if let Some(id) = current_id {
|
||||
query = query.filter(categories::Column::Id.ne(id));
|
||||
}
|
||||
Ok(query.count(&ctx.db).await? > 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(CategoryFields {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
position,
|
||||
published,
|
||||
parent_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the parent-category dropdown options for the category form, as a
|
||||
/// depth-ordered list of `{ id, name, depth }`. When editing, the category
|
||||
/// itself and all of its descendants are excluded to keep the tree acyclic.
|
||||
async fn form_context(
|
||||
ctx: &AppContext,
|
||||
jar: &CookieJar,
|
||||
editing: Option<i32>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let all = categories::all(ctx).await?;
|
||||
let blocked: HashSet<i32> = match editing {
|
||||
Some(id) => {
|
||||
let mut set = categories::descendant_ids(&all, id);
|
||||
set.insert(id);
|
||||
set
|
||||
}
|
||||
None => HashSet::new(),
|
||||
};
|
||||
let parents: Vec<serde_json::Value> = categories::tree(&all)
|
||||
.into_iter()
|
||||
.filter(|(category, _)| !blocked.contains(&category.id))
|
||||
.map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth }))
|
||||
.collect();
|
||||
Ok(json!({ "parents": parents, "lang": current_lang(jar) }))
|
||||
}
|
||||
|
||||
#[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 list = categories::all(&ctx).await?;
|
||||
let mut rows = Vec::new();
|
||||
for (category, depth) in categories::tree(&list) {
|
||||
let product_count = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
rows.push(json!({ "category": category, "depth": depth, "product_count": product_count }));
|
||||
}
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/categories.html",
|
||||
json!({ "categories": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let mut context = form_context(&ctx, &jar, None).await?;
|
||||
context["category"] = serde_json::Value::Null;
|
||||
format::view(&v, "admin/catalog/category_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let fields = parse_category_fields(&ctx, &form, None).await?;
|
||||
let image_id = match form.image {
|
||||
Some(data) => Some(store_image(&ctx, data).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
categories::ActiveModel {
|
||||
name: Set(fields.name),
|
||||
slug: Set(fields.slug),
|
||||
description: Set(fields.description),
|
||||
image_id: Set(image_id),
|
||||
position: Set(fields.position),
|
||||
published: Set(fields.published),
|
||||
parent_id: Set(fields.parent_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let mut context = form_context(&ctx, &jar, Some(id)).await?;
|
||||
context["category"] = json!(category_by_id(&ctx, id).await?);
|
||||
format::view(&v, "admin/catalog/category_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let existing = category_by_id(&ctx, id).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let fields = parse_category_fields(&ctx, &form, Some(id)).await?;
|
||||
|
||||
let mut category = existing.into_active_model();
|
||||
category.name = Set(fields.name);
|
||||
category.slug = Set(fields.slug);
|
||||
category.description = Set(fields.description);
|
||||
category.position = Set(fields.position);
|
||||
category.published = Set(fields.published);
|
||||
category.parent_id = Set(fields.parent_id);
|
||||
if let Some(data) = form.image {
|
||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||
}
|
||||
category.update(&ctx.db).await?;
|
||||
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||
Routes::new()
|
||||
.add("/admin/catalog/categories", get(index))
|
||||
.add("/admin/catalog/categories/new", get(new))
|
||||
.add(
|
||||
"/admin/catalog/categories",
|
||||
post(create).layer(image_limit.clone()),
|
||||
)
|
||||
.add("/admin/catalog/categories/{id}/edit", get(edit))
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}",
|
||||
post(update).layer(image_limit),
|
||||
)
|
||||
.add("/admin/catalog/categories/{id}/delete", post(delete))
|
||||
}
|
||||
87
src/admin/form.rs
Normal file
87
src/admin/form.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Multipart form handling shared by the product and category admin forms.
|
||||
//!
|
||||
//! Both forms submit a mix of text fields and an optional `image` file part;
|
||||
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
|
||||
//! uploaded image through the configured storage driver.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::Multipart;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
||||
|
||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
})
|
||||
}
|
||||
|
||||
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
||||
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
||||
pub(crate) struct MultipartForm {
|
||||
fields: HashMap<String, String>,
|
||||
pub(crate) image: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MultipartForm {
|
||||
/// Trimmed value of a text field, `None` when missing or blank.
|
||||
pub(crate) fn text(&self, key: &str) -> Option<String> {
|
||||
normalize_empty(self.fields.get(key).cloned())
|
||||
}
|
||||
|
||||
/// Whether a checkbox-style field is checked.
|
||||
pub(crate) fn checked(&self, key: &str) -> bool {
|
||||
matches!(
|
||||
self.fields.get(key).map(String::as_str),
|
||||
Some("on" | "true" | "1")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||
let mut fields = HashMap::new();
|
||||
let mut image = None;
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "image" {
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
||||
{
|
||||
data.extend_from_slice(&chunk);
|
||||
if data.len() > IMAGE_MAX_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"image is larger than {} MB",
|
||||
IMAGE_MAX_BYTES / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
image = Some(data);
|
||||
}
|
||||
} else {
|
||||
let value = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||
fields.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MultipartForm { fields, image })
|
||||
}
|
||||
|
||||
/// Store an uploaded image's bytes and return its generated filename.
|
||||
pub(crate) async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
|
||||
let extension = detect_image_extension(&data)?;
|
||||
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
|
||||
}
|
||||
85
src/admin/login.rs
Normal file
85
src/admin/login.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
|
||||
//! flow used by the SPA/API).
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
account::{self as auth_controller, models::users::{self, LoginParams}},
|
||||
i18n::current_lang,
|
||||
shared::guard,
|
||||
};
|
||||
|
||||
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
|
||||
format::view(
|
||||
v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": "Invalid credentials",
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn login_page(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
if guard::logged_in(&ctx, &jar).await {
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": null,
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn login(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<LoginParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
return login_error(&v, &jar);
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.password) || !guard::is_admin(&ctx, &user) {
|
||||
return login_error(&v, &jar);
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
||||
.redirect("/admin/dashboard")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn logout() -> Result<Response> {
|
||||
format::render()
|
||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||
.redirect("/admin/login")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin", get(login_page))
|
||||
.add("/admin/login", get(login_page))
|
||||
.add("/admin/login", post(login))
|
||||
.add("/admin/logout", post(logout))
|
||||
}
|
||||
63
src/admin/mod.rs
Normal file
63
src/admin/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Admin area. Each surface lives in its own submodule; this module holds the
|
||||
//! dashboard (HTML home + JSON stats) and is the entry point for admin routes.
|
||||
|
||||
pub mod categories;
|
||||
pub mod form;
|
||||
pub mod login;
|
||||
pub mod models;
|
||||
pub mod orders;
|
||||
pub mod products;
|
||||
pub mod shipping;
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{i18n::current_lang, models::_entities, shared::guard};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DashboardResponse {
|
||||
users: u64,
|
||||
products: u64,
|
||||
categories: u64,
|
||||
orders: u64,
|
||||
audit_logs: u64,
|
||||
}
|
||||
|
||||
/// JSON dashboard stats, served under `/api/admin`.
|
||||
#[debug_handler]
|
||||
async fn dashboard_json(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
|
||||
format::json(DashboardResponse {
|
||||
users: _entities::users::Entity::find().count(&ctx.db).await?,
|
||||
products: _entities::products::Entity::find().count(&ctx.db).await?,
|
||||
categories: _entities::categories::Entity::find().count(&ctx.db).await?,
|
||||
orders: _entities::orders::Entity::find().count(&ctx.db).await?,
|
||||
audit_logs: _entities::audit_logs::Entity::find().count(&ctx.db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// HTML admin home page.
|
||||
#[debug_handler]
|
||||
async fn dashboard_page(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = guard::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/index.html",
|
||||
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/dashboard", get(dashboard_page))
|
||||
.add("/api/admin/dashboard", get(dashboard_json))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub use super::_entities::audit_logs::{ActiveModel, Entity, Model};
|
||||
pub use crate::models::_entities::audit_logs::{ActiveModel, Entity, Model};
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub type AuditLogs = Entity;
|
||||
|
||||
1
src/admin/models/mod.rs
Normal file
1
src/admin/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod audit_logs;
|
||||
106
src/admin/orders.rs
Normal file
106
src/admin/orders.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! Admin order list, detail, and status updates.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
checkout::{
|
||||
models::{order_items, orders},
|
||||
view,
|
||||
},
|
||||
i18n::current_lang,
|
||||
shared::{guard, settings},
|
||||
};
|
||||
|
||||
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StatusForm {
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[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 list = orders::Entity::find()
|
||||
.order_by_desc(orders::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/index.html",
|
||||
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn show(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let items = order_items::Entity::find()
|
||||
.filter(order_items::Column::OrderId.eq(order.id))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/show.html",
|
||||
json!({
|
||||
"order": view::detail(
|
||||
&order,
|
||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
||||
),
|
||||
"items": view::items(&items),
|
||||
"statuses": ORDER_STATUSES,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update_status(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<StatusForm>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
if !ORDER_STATUSES.contains(&form.status.as_str()) {
|
||||
return Err(Error::BadRequest("invalid status".to_string()));
|
||||
}
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let mut active = order.into_active_model();
|
||||
active.status = Set(form.status);
|
||||
active.update(&ctx.db).await?;
|
||||
|
||||
format::redirect(&format!("/admin/orders/{id}"))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/orders", get(index))
|
||||
.add("/admin/orders/{id}", get(show))
|
||||
.add("/admin/orders/{id}/status", post(update_status))
|
||||
}
|
||||
286
src/admin/products.rs
Normal file
286
src/admin/products.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! Admin product CRUD.
|
||||
|
||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
shared::{
|
||||
guard,
|
||||
money::parse_price_to_cents,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
shop::{
|
||||
models::{categories, product_images, products},
|
||||
view,
|
||||
},
|
||||
};
|
||||
|
||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
products::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
/// Fields parsed from a product form, ready to apply to an active model.
|
||||
struct ProductFields {
|
||||
name: String,
|
||||
slug: String,
|
||||
description: Option<String>,
|
||||
price_cents: i64,
|
||||
currency: String,
|
||||
sku: Option<String>,
|
||||
stock: i32,
|
||||
category_id: Option<i32>,
|
||||
published: bool,
|
||||
}
|
||||
|
||||
async fn parse_product_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<ProductFields> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
||||
let price_cents = parse_price_to_cents(
|
||||
form.text("price")
|
||||
.ok_or_else(|| Error::BadRequest("price is required".to_string()))?
|
||||
.as_str(),
|
||||
)?;
|
||||
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
||||
let description = form.text("description");
|
||||
let sku = form.text("sku");
|
||||
let stock = form
|
||||
.text("stock")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.filter(|n| *n >= 0)
|
||||
.unwrap_or(0);
|
||||
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
||||
let published = form.checked("published");
|
||||
|
||||
let desired = form
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| slugify(&name));
|
||||
let slug = unique_slug(&desired, |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
let mut query = products::Entity::find().filter(products::Column::Slug.eq(candidate));
|
||||
if let Some(id) = current_id {
|
||||
query = query.filter(products::Column::Id.ne(id));
|
||||
}
|
||||
Ok(query.count(&ctx.db).await? > 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(ProductFields {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
price_cents,
|
||||
currency,
|
||||
sku,
|
||||
stock,
|
||||
category_id,
|
||||
published,
|
||||
})
|
||||
}
|
||||
|
||||
async fn form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
|
||||
let categories = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.order_by_asc(categories::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
Ok(json!({ "categories": categories, "lang": current_lang(jar) }))
|
||||
}
|
||||
|
||||
#[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 list = products::Entity::find()
|
||||
.order_by_desc(products::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = product_images::first_for(&ctx, product.id).await?;
|
||||
let category_name = match product.category_id {
|
||||
Some(id) => categories::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|c| c.name),
|
||||
None => None,
|
||||
};
|
||||
rows.push(view::product_card(&product, image, category_name));
|
||||
}
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/products.html",
|
||||
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let mut context = form_context(&ctx, &jar).await?;
|
||||
context["product"] = serde_json::Value::Null;
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let fields = parse_product_fields(&ctx, &form, None).await?;
|
||||
|
||||
let product = products::ActiveModel {
|
||||
name: Set(fields.name),
|
||||
slug: Set(fields.slug),
|
||||
description: Set(fields.description),
|
||||
price_cents: Set(fields.price_cents),
|
||||
currency: Set(fields.currency),
|
||||
sku: Set(fields.sku),
|
||||
stock: Set(fields.stock),
|
||||
view_count: Set(0),
|
||||
published: Set(fields.published),
|
||||
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
|
||||
category_id: Set(fields.category_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(product.id),
|
||||
image_id: Set(filename),
|
||||
position: Set(0),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
let image = product_images::first_for(&ctx, id).await?;
|
||||
let mut context = form_context(&ctx, &jar).await?;
|
||||
context["product"] = view::product_form(&product, image);
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let existing = product_by_id(&ctx, id).await?;
|
||||
let was_published = existing.published;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let fields = parse_product_fields(&ctx, &form, Some(id)).await?;
|
||||
|
||||
let mut product = existing.into_active_model();
|
||||
product.name = Set(fields.name);
|
||||
product.slug = Set(fields.slug);
|
||||
product.description = Set(fields.description);
|
||||
product.price_cents = Set(fields.price_cents);
|
||||
product.currency = Set(fields.currency);
|
||||
product.sku = Set(fields.sku);
|
||||
product.stock = Set(fields.stock);
|
||||
product.category_id = Set(fields.category_id);
|
||||
product.published = Set(fields.published);
|
||||
if fields.published && !was_published {
|
||||
product.published_at = Set(Some(chrono::Utc::now().into()));
|
||||
} else if !fields.published {
|
||||
product.published_at = Set(None);
|
||||
}
|
||||
product.update(&ctx.db).await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
let next_position = product_images::count_for(&ctx, id).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(id),
|
||||
image_id: Set(filename),
|
||||
position: Set(next_position),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||
Routes::new()
|
||||
.add("/admin/catalog/products", get(index))
|
||||
.add("/admin/catalog/products/new", get(new))
|
||||
.add(
|
||||
"/admin/catalog/products",
|
||||
post(create).layer(image_limit.clone()),
|
||||
)
|
||||
.add("/admin/catalog/products/{id}/edit", get(edit))
|
||||
.add(
|
||||
"/admin/catalog/products/{id}",
|
||||
post(update).layer(image_limit),
|
||||
)
|
||||
.add("/admin/catalog/products/{id}/delete", post(delete))
|
||||
}
|
||||
79
src/admin/shipping.rs
Normal file
79
src/admin/shipping.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Admin management of shipping methods (price + enabled toggle).
|
||||
|
||||
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::{
|
||||
checkout::models::shipping_methods,
|
||||
i18n::current_lang,
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ShippingForm {
|
||||
price: String,
|
||||
enabled: Option<String>,
|
||||
}
|
||||
|
||||
#[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 methods = shipping_methods::Entity::find()
|
||||
.order_by_asc(shipping_methods::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let rows: Vec<serde_json::Value> = methods
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"code": m.code,
|
||||
"name": m.name,
|
||||
"price": format_price(m.price_cents),
|
||||
"requires_pickup_point": m.requires_pickup_point,
|
||||
"enabled": m.enabled,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
format::view(
|
||||
&v,
|
||||
"admin/shipping/index.html",
|
||||
json!({ "methods": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<ShippingForm>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let method = shipping_methods::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let mut active = method.into_active_model();
|
||||
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
||||
active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1")));
|
||||
active.update(&ctx.db).await?;
|
||||
format::redirect("/admin/shipping")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/shipping", get(index))
|
||||
.add("/admin/shipping/{id}", post(update))
|
||||
}
|
||||
34
src/app.rs
34
src/app.rs
@@ -16,7 +16,8 @@ use std::{path::Path, sync::Arc};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker,
|
||||
account, admin, cart, checkout, home, i18n, initializers, media,
|
||||
models::_entities::users, shop, tasks, workers::downloader::DownloadWorker,
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
@@ -53,24 +54,33 @@ impl Hooks for App {
|
||||
Ok(vec![
|
||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||
])
|
||||
}
|
||||
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::auth::routes())
|
||||
.add_route(controllers::admin::routes())
|
||||
.add_route(controllers::catalog::routes())
|
||||
.add_route(controllers::cart::routes())
|
||||
.add_route(controllers::orders::routes())
|
||||
.add_route(controllers::i18n::routes())
|
||||
.add_route(controllers::media::routes())
|
||||
.add_route(controllers::frontend::routes())
|
||||
AppRoutes::with_default_routes() // feature routes below
|
||||
// public
|
||||
.add_route(home::routes())
|
||||
.add_route(shop::routes())
|
||||
.add_route(cart::routes())
|
||||
.add_route(checkout::routes())
|
||||
// cross-cutting
|
||||
.add_route(account::routes())
|
||||
.add_route(i18n::routes())
|
||||
.add_route(media::routes())
|
||||
// admin
|
||||
.add_route(admin::routes())
|
||||
.add_route(admin::login::routes())
|
||||
.add_route(admin::products::routes())
|
||||
.add_route(admin::categories::routes())
|
||||
.add_route(admin::orders::routes())
|
||||
.add_route(admin::shipping::routes())
|
||||
}
|
||||
|
||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||
let upload_root = crate::controllers::media::uploads_root(&ctx.config)?;
|
||||
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?;
|
||||
let upload_root = media::uploads_root(&ctx.config)?;
|
||||
tokio::fs::create_dir_all(upload_root.join(media::IMAGE_STORAGE_DIR)).await?;
|
||||
|
||||
let driver = storage::drivers::local::new_with_prefix(&upload_root)?;
|
||||
Ok(AppContext {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use crate::{
|
||||
controllers::{catalog::format_price, i18n::current_lang},
|
||||
models::_entities::products,
|
||||
};
|
||||
use crate::{i18n::current_lang, shared::money::format_price, shop::models::products};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
206
src/checkout/mod.rs
Normal file
206
src/checkout/mod.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! Public checkout flow: the checkout form, placing an order, and the order
|
||||
//! confirmation page.
|
||||
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
cart::{resolve_cart, CART_COOKIE},
|
||||
checkout::models::{
|
||||
order_items,
|
||||
orders::{self, Checkout},
|
||||
shipping_methods,
|
||||
},
|
||||
i18n::current_lang,
|
||||
shared::{money::format_price, settings},
|
||||
};
|
||||
|
||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CheckoutForm {
|
||||
email: String,
|
||||
customer_name: String,
|
||||
address: String,
|
||||
city: String,
|
||||
zip: String,
|
||||
country: String,
|
||||
note: Option<String>,
|
||||
payment_method: String,
|
||||
carrier_code: String,
|
||||
pickup_point_id: Option<String>,
|
||||
pickup_point_name: Option<String>,
|
||||
}
|
||||
|
||||
fn trimmed(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!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>> {
|
||||
Ok(shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||
.order_by_asc(shipping_methods::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn checkout_page(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
|
||||
if lines.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
let currency = lines
|
||||
.first()
|
||||
.and_then(|line| line["currency"].as_str())
|
||||
.unwrap_or("EUR")
|
||||
.to_string();
|
||||
|
||||
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"code": m.code,
|
||||
"name": m.name,
|
||||
"price_cents": m.price_cents,
|
||||
"price": format_price(m.price_cents),
|
||||
"requires_pickup_point": m.requires_pickup_point,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/checkout.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"subtotal": format_price(subtotal),
|
||||
"subtotal_cents": subtotal,
|
||||
"currency": currency,
|
||||
"shipping_methods": methods,
|
||||
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn place_order(
|
||||
jar: CookieJar,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<CheckoutForm>,
|
||||
) -> Result<Response> {
|
||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
|
||||
if valid.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
let email =
|
||||
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
||||
|
||||
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
|
||||
return Err(Error::BadRequest("invalid payment method".to_string()));
|
||||
}
|
||||
|
||||
// Resolve the chosen carrier from the enabled methods (price is taken from
|
||||
// the DB, never the form, so the customer can't pick their own fee).
|
||||
let method = shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(&form.carrier_code))
|
||||
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
|
||||
|
||||
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
|
||||
let id = form
|
||||
.pickup_point_id
|
||||
.as_deref()
|
||||
.and_then(trimmed)
|
||||
.ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?;
|
||||
(Some(id), form.pickup_point_name.as_deref().and_then(trimmed))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let order = orders::place(
|
||||
&ctx,
|
||||
&valid,
|
||||
Checkout {
|
||||
email,
|
||||
customer_name: trimmed(&form.customer_name),
|
||||
address: trimmed(&form.address),
|
||||
city: trimmed(&form.city),
|
||||
zip: trimmed(&form.zip),
|
||||
country: trimmed(&form.country),
|
||||
note: form.note.as_deref().and_then(trimmed),
|
||||
payment_method: form.payment_method,
|
||||
method,
|
||||
pickup_point_id,
|
||||
pickup_point_name,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[cleared_cart_cookie()])?
|
||||
.redirect(&format!("/orders/{}", order.order_number))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn order_confirmation(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(order_number): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let order = orders::Entity::find()
|
||||
.filter(orders::Column::OrderNumber.eq(order_number))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let items = order_items::Entity::find()
|
||||
.filter(order_items::Column::OrderId.eq(order.id))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/order_confirmed.html",
|
||||
json!({
|
||||
"order": view::detail(
|
||||
&order,
|
||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
||||
),
|
||||
"items": view::items(&items),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/checkout", get(checkout_page))
|
||||
.add("/checkout", post(place_order))
|
||||
.add("/orders/{order_number}", get(order_confirmation))
|
||||
}
|
||||
3
src/checkout/models/mod.rs
Normal file
3
src/checkout/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod order_items;
|
||||
pub mod orders;
|
||||
pub mod shipping_methods;
|
||||
@@ -1,5 +1,5 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::order_items::{ActiveModel, Model, Entity};
|
||||
pub use crate::models::_entities::order_items::{ActiveModel, Column, Entity, Model};
|
||||
pub type OrderItems = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
127
src/checkout/models/orders.rs
Normal file
127
src/checkout/models/orders.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{Set, TransactionTrait};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::_entities::{order_items, products, shipping_methods};
|
||||
pub use crate::models::_entities::orders::{ActiveModel, Column, Entity, Model};
|
||||
pub type Orders = Entity;
|
||||
|
||||
/// The customer-supplied and carrier details needed to place an order. Prices
|
||||
/// and product names are never taken from here — they are snapshotted from the
|
||||
/// database inside [`place`] so the customer cannot influence what they pay.
|
||||
pub struct Checkout {
|
||||
pub email: String,
|
||||
pub customer_name: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub city: Option<String>,
|
||||
pub zip: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub note: Option<String>,
|
||||
pub payment_method: String,
|
||||
pub method: shipping_methods::Model,
|
||||
pub pickup_point_id: Option<String>,
|
||||
pub pickup_point_name: Option<String>,
|
||||
}
|
||||
|
||||
fn generate_order_number() -> String {
|
||||
let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase();
|
||||
format!("ORD-{suffix}")
|
||||
}
|
||||
|
||||
/// Atomically place an order for the given `(product_id, quantity)` lines:
|
||||
/// snapshot each product's price/name, decrement stock (re-checking inside the
|
||||
/// transaction so an item can't oversell between cart and pay), then write the
|
||||
/// order and its line items. Returns the persisted order.
|
||||
pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) -> Result<Model> {
|
||||
let txn = ctx.db.begin().await?;
|
||||
|
||||
let mut subtotal: i64 = 0;
|
||||
let mut currency = "EUR".to_string();
|
||||
let mut snapshots = Vec::new();
|
||||
for (product_id, qty) in items {
|
||||
let product = products::Entity::find_by_id(*product_id)
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
||||
if product.stock < *qty {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"not enough stock for {}",
|
||||
product.name
|
||||
)));
|
||||
}
|
||||
currency = product.currency.clone();
|
||||
subtotal += product.price_cents * i64::from(*qty);
|
||||
|
||||
let mut active = product.clone().into_active_model();
|
||||
active.stock = Set(product.stock - *qty);
|
||||
active.update(&txn).await?;
|
||||
|
||||
snapshots.push((product.id, product.name, product.price_cents, *qty));
|
||||
}
|
||||
|
||||
let order = ActiveModel {
|
||||
order_number: Set(generate_order_number()),
|
||||
email: Set(details.email),
|
||||
customer_name: Set(details.customer_name),
|
||||
status: Set("pending".to_string()),
|
||||
total_cents: Set(subtotal + details.method.price_cents),
|
||||
currency: Set(currency),
|
||||
address: Set(details.address),
|
||||
city: Set(details.city),
|
||||
zip: Set(details.zip),
|
||||
country: Set(details.country),
|
||||
note: Set(details.note),
|
||||
payment_method: Set(Some(details.payment_method)),
|
||||
carrier_code: Set(Some(details.method.code)),
|
||||
carrier_name: Set(Some(details.method.name)),
|
||||
shipping_cents: Set(details.method.price_cents),
|
||||
pickup_point_id: Set(details.pickup_point_id),
|
||||
pickup_point_name: Set(details.pickup_point_name),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
for (product_id, name, unit_price_cents, qty) in snapshots {
|
||||
order_items::ActiveModel {
|
||||
order_id: Set(order.id),
|
||||
product_id: Set(Some(product_id)),
|
||||
product_name: Set(name),
|
||||
unit_price_cents: Set(unit_price_cents),
|
||||
quantity: Set(qty),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
#[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 {}
|
||||
@@ -1,6 +1,6 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::categories::{ActiveModel, Model, Entity};
|
||||
pub type Categories = Entity;
|
||||
pub use crate::models::_entities::shipping_methods::{ActiveModel, Column, Entity, Model};
|
||||
pub type ShippingMethods = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
64
src/checkout/view.rs
Normal file
64
src/checkout/view.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! JSON shaping for order confirmation and admin order templates.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::models::_entities::{order_items, orders};
|
||||
use crate::shared::money::format_price;
|
||||
|
||||
/// Line items of an order, shaped for templates.
|
||||
pub fn items(items: &[order_items::Model]) -> Vec<Value> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
json!({
|
||||
"product_name": item.product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_price(item.unit_price_cents),
|
||||
"line_total": format_price(item.unit_price_cents * i64::from(item.quantity)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Full order detail for the confirmation and admin show pages. `bank_iban` and
|
||||
/// `bank_account_name` come from settings and are embedded for bank-transfer
|
||||
/// payment instructions.
|
||||
pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -> Value {
|
||||
json!({
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"email": order.email,
|
||||
"customer_name": order.customer_name,
|
||||
"status": order.status,
|
||||
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
||||
"shipping": format_price(order.shipping_cents),
|
||||
"total": format_price(order.total_cents),
|
||||
"currency": order.currency,
|
||||
"address": order.address,
|
||||
"city": order.city,
|
||||
"zip": order.zip,
|
||||
"country": order.country,
|
||||
"note": order.note,
|
||||
"payment_method": order.payment_method,
|
||||
"carrier_name": order.carrier_name,
|
||||
"pickup_point_name": order.pickup_point_name,
|
||||
// Numeric, sequential order id doubles as the bank variable symbol.
|
||||
"variable_symbol": order.id,
|
||||
"bank_iban": bank_iban,
|
||||
"bank_account_name": bank_account_name,
|
||||
"created_at": order.created_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compact row for the admin orders list.
|
||||
pub fn summary(order: &orders::Model) -> Value {
|
||||
json!({
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"email": order.email,
|
||||
"status": order.status,
|
||||
"total": format_price(order.total_cents),
|
||||
"currency": order.currency,
|
||||
"created_at": order.created_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use crate::models::{
|
||||
_entities::{audit_logs, categories, orders, products, users},
|
||||
users as users_model,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DashboardResponse {
|
||||
users: u64,
|
||||
products: u64,
|
||||
categories: u64,
|
||||
orders: u64,
|
||||
audit_logs: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
||||
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
|
||||
if !is_admin(ctx, &user) {
|
||||
return unauthorized("admin only");
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
current_admin(auth, &ctx).await?;
|
||||
|
||||
format::json(DashboardResponse {
|
||||
users: users::Entity::find().count(&ctx.db).await?,
|
||||
products: products::Entity::find().count(&ctx.db).await?,
|
||||
categories: categories::Entity::find().count(&ctx.db).await?,
|
||||
orders: orders::Entity::find().count(&ctx.db).await?,
|
||||
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
.add("/dashboard", get(dashboard))
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin,
|
||||
auth as auth_controller,
|
||||
i18n::current_lang,
|
||||
media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR},
|
||||
},
|
||||
models::{
|
||||
_entities::{categories, product_images, products},
|
||||
users,
|
||||
},
|
||||
};
|
||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for ch in value.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
|
||||
/// minor units (cents). Rejects negatives and more than two decimals.
|
||||
fn parse_price_to_cents(value: &str) -> Result<i64> {
|
||||
let value = value.trim().replace(',', ".");
|
||||
let invalid = || Error::BadRequest("invalid price".to_string());
|
||||
let (whole, frac) = match value.split_once('.') {
|
||||
Some((w, f)) => (w, f),
|
||||
None => (value.as_str(), ""),
|
||||
};
|
||||
if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
if !frac.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let whole: i64 = whole.parse().map_err(|_| invalid())?;
|
||||
let cents: i64 = match frac.len() {
|
||||
0 => 0,
|
||||
1 => frac.parse::<i64>().map_err(|_| invalid())? * 10,
|
||||
_ => frac.parse().map_err(|_| invalid())?,
|
||||
};
|
||||
Ok(whole * 100 + cents)
|
||||
}
|
||||
|
||||
/// Render minor units as a human price string, e.g. `1234` -> `"12.34"`.
|
||||
pub(crate) fn format_price(cents: i64) -> String {
|
||||
format!("{}.{:02}", cents / 100, (cents % 100).abs())
|
||||
}
|
||||
|
||||
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
||||
return false;
|
||||
};
|
||||
admin::is_admin(ctx, &user)
|
||||
}
|
||||
|
||||
async fn unique_slug<F, Fut>(base: &str, mut exists: F) -> Result<String>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
let base = if base.is_empty() {
|
||||
"item".to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
let mut slug = base.clone();
|
||||
let mut suffix = 2;
|
||||
while exists(slug.clone()).await? {
|
||||
slug = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
|
||||
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
||||
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
||||
struct MultipartForm {
|
||||
fields: HashMap<String, String>,
|
||||
image: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MultipartForm {
|
||||
fn text(&self, key: &str) -> Option<String> {
|
||||
normalize_empty(self.fields.get(key).cloned())
|
||||
}
|
||||
|
||||
fn checked(&self, key: &str) -> bool {
|
||||
matches!(self.fields.get(key).map(String::as_str), Some("on" | "true" | "1"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||
let mut fields = HashMap::new();
|
||||
let mut image = None;
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "image" {
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
||||
{
|
||||
data.extend_from_slice(&chunk);
|
||||
if data.len() > IMAGE_MAX_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"image is larger than {} MB",
|
||||
IMAGE_MAX_BYTES / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
image = Some(data);
|
||||
}
|
||||
} else {
|
||||
let value = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||
fields.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MultipartForm { fields, image })
|
||||
}
|
||||
|
||||
async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
|
||||
let extension = detect_image_extension(&data)?;
|
||||
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
|
||||
}
|
||||
|
||||
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
categories::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
products::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
||||
Ok(product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(product_id))
|
||||
.order_by_asc(product_images::Column::Position)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|image| image.image_id))
|
||||
}
|
||||
|
||||
/// Shape a product for templates: the model fields plus a formatted price,
|
||||
/// its (optional) primary image filename and category name.
|
||||
fn product_json(
|
||||
product: &products::Model,
|
||||
image: Option<String>,
|
||||
category_name: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": format_price(product.price_cents),
|
||||
"currency": product.currency,
|
||||
"sku": product.sku,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"image": image,
|
||||
"category_name": category_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Latest published products (with primary image), shaped for templates.
|
||||
/// Reused by the home page landing grid.
|
||||
pub(crate) async fn featured_products(
|
||||
ctx: &AppContext,
|
||||
limit: u64,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.limit(limit)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(ctx, product.id).await?;
|
||||
rows.push(product_json(&product, image, None));
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: products
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_products(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = products::Entity::find()
|
||||
.order_by_desc(products::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
let category_name = match product.category_id {
|
||||
Some(id) => category_by_id(&ctx, id).await.ok().map(|c| c.name),
|
||||
None => None,
|
||||
};
|
||||
rows.push(product_json(&product, image, category_name));
|
||||
}
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/products.html",
|
||||
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn product_form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
|
||||
let categories = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.order_by_asc(categories::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
Ok(json!({ "categories": categories, "lang": current_lang(jar) }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut context = product_form_context(&ctx, &jar).await?;
|
||||
context["product"] = serde_json::Value::Null;
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
|
||||
async fn parse_product_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<(String, String, Option<String>, i64, String, Option<String>, i32, Option<i32>, bool)> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
||||
let price_cents = parse_price_to_cents(
|
||||
form.text("price")
|
||||
.ok_or_else(|| Error::BadRequest("price is required".to_string()))?
|
||||
.as_str(),
|
||||
)?;
|
||||
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
||||
let description = form.text("description");
|
||||
let sku = form.text("sku");
|
||||
let stock = form
|
||||
.text("stock")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.filter(|n| *n >= 0)
|
||||
.unwrap_or(0);
|
||||
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
||||
let published = form.checked("published");
|
||||
|
||||
let desired = form
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| slugify(&name));
|
||||
let slug = unique_slug(&desired, |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
let mut query =
|
||||
products::Entity::find().filter(products::Column::Slug.eq(candidate));
|
||||
if let Some(id) = current_id {
|
||||
query = query.filter(products::Column::Id.ne(id));
|
||||
}
|
||||
Ok(query.count(&ctx.db).await? > 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
name, slug, description, price_cents, currency, sku, stock, category_id, published,
|
||||
))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, price_cents, currency, sku, stock, category_id, published) =
|
||||
parse_product_fields(&ctx, &form, None).await?;
|
||||
|
||||
let product = products::ActiveModel {
|
||||
name: Set(name),
|
||||
slug: Set(slug),
|
||||
description: Set(description),
|
||||
price_cents: Set(price_cents),
|
||||
currency: Set(currency),
|
||||
sku: Set(sku),
|
||||
stock: Set(stock),
|
||||
view_count: Set(0),
|
||||
published: Set(published),
|
||||
published_at: Set(published.then(|| chrono::Utc::now().into())),
|
||||
category_id: Set(category_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(product.id),
|
||||
image_id: Set(filename),
|
||||
position: Set(0),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
let image = first_image(&ctx, id).await?;
|
||||
let mut context = product_form_context(&ctx, &jar).await?;
|
||||
context["product"] = json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": format_price(product.price_cents),
|
||||
"currency": product.currency,
|
||||
"sku": product.sku,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"category_id": product.category_id,
|
||||
"image": image,
|
||||
});
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = product_by_id(&ctx, id).await?;
|
||||
let was_published = existing.published;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, price_cents, currency, sku, stock, category_id, published) =
|
||||
parse_product_fields(&ctx, &form, Some(id)).await?;
|
||||
|
||||
let mut product = existing.into_active_model();
|
||||
product.name = Set(name);
|
||||
product.slug = Set(slug);
|
||||
product.description = Set(description);
|
||||
product.price_cents = Set(price_cents);
|
||||
product.currency = Set(currency);
|
||||
product.sku = Set(sku);
|
||||
product.stock = Set(stock);
|
||||
product.category_id = Set(category_id);
|
||||
product.published = Set(published);
|
||||
if published && !was_published {
|
||||
product.published_at = Set(Some(chrono::Utc::now().into()));
|
||||
} else if !published {
|
||||
product.published_at = Set(None);
|
||||
}
|
||||
product.update(&ctx.db).await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
let next_position = product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(id))
|
||||
.count(&ctx.db)
|
||||
.await? as i32;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(id),
|
||||
image_id: Set(filename),
|
||||
position: Set(next_position),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_categories(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.order_by_asc(categories::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for category in list {
|
||||
let product_count = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
rows.push(json!({ "category": category, "product_count": product_count }));
|
||||
}
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/categories.html",
|
||||
json!({ "categories": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/category_form.html",
|
||||
json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn parse_category_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<(String, String, Option<String>, i32, bool)> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||
let description = form.text("description");
|
||||
let position = form
|
||||
.text("position")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let published = form.checked("published");
|
||||
|
||||
let desired = form
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| slugify(&name));
|
||||
let slug = unique_slug(&desired, |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
let mut query =
|
||||
categories::Entity::find().filter(categories::Column::Slug.eq(candidate));
|
||||
if let Some(id) = current_id {
|
||||
query = query.filter(categories::Column::Id.ne(id));
|
||||
}
|
||||
Ok(query.count(&ctx.db).await? > 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((name, slug, description, position, published))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, position, published) =
|
||||
parse_category_fields(&ctx, &form, None).await?;
|
||||
let image_id = match form.image {
|
||||
Some(data) => Some(store_image(&ctx, data).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
categories::ActiveModel {
|
||||
name: Set(name),
|
||||
slug: Set(slug),
|
||||
description: Set(description),
|
||||
image_id: Set(image_id),
|
||||
position: Set(position),
|
||||
published: Set(published),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/category_form.html",
|
||||
json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = category_by_id(&ctx, id).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, position, published) =
|
||||
parse_category_fields(&ctx, &form, Some(id)).await?;
|
||||
|
||||
let mut category = existing.into_active_model();
|
||||
category.name = Set(name);
|
||||
category.slug = Set(slug);
|
||||
category.description = Set(description);
|
||||
category.position = Set(position);
|
||||
category.published = Set(published);
|
||||
if let Some(data) = form.image {
|
||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||
}
|
||||
category.update(&ctx.db).await?;
|
||||
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public storefront
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_index(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
rows.push(product_json(&product, image, None));
|
||||
}
|
||||
let categories = categories::Entity::find()
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"categories": categories,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_show(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let product = products::Entity::find()
|
||||
.filter(products::Column::Slug.eq(slug))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let mut active = product.clone().into_active_model();
|
||||
active.view_count = Set(product.view_count + 1);
|
||||
let product = active.update(&ctx.db).await?;
|
||||
|
||||
let images = product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(product.id))
|
||||
.order_by_asc(product_images::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let category = match product.category_id {
|
||||
Some(id) => category_by_id(&ctx, id).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/show.html",
|
||||
json!({
|
||||
"product": product_json(&product, None, category.as_ref().map(|c| c.name.clone())),
|
||||
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
|
||||
"category": category,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_category(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let category = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(slug))
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
rows.push(product_json(&product, image, None));
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/category.html",
|
||||
json!({
|
||||
"category": category,
|
||||
"products": rows,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||
Routes::new()
|
||||
// public storefront
|
||||
.add("/shop", get(shop_index))
|
||||
.add("/shop/{slug}", get(shop_show))
|
||||
.add("/category/{slug}", get(shop_category))
|
||||
// admin products
|
||||
.add("/admin/catalog/products", get(admin_products))
|
||||
.add("/admin/catalog/products/new", get(admin_product_new))
|
||||
.add(
|
||||
"/admin/catalog/products",
|
||||
post(admin_product_create).layer(image_limit.clone()),
|
||||
)
|
||||
.add("/admin/catalog/products/{id}/edit", get(admin_product_edit))
|
||||
.add(
|
||||
"/admin/catalog/products/{id}",
|
||||
post(admin_product_update).layer(image_limit.clone()),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/delete",
|
||||
post(admin_product_delete),
|
||||
)
|
||||
// admin categories
|
||||
.add("/admin/catalog/categories", get(admin_categories))
|
||||
.add("/admin/catalog/categories/new", get(admin_category_new))
|
||||
.add(
|
||||
"/admin/catalog/categories",
|
||||
post(admin_category_create).layer(image_limit.clone()),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}/edit",
|
||||
get(admin_category_edit),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}",
|
||||
post(admin_category_update).layer(image_limit),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}/delete",
|
||||
post(admin_category_delete),
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
use crate::{
|
||||
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
||||
models::users::{self, LoginParams},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
||||
return false;
|
||||
};
|
||||
|
||||
admin::is_admin(ctx, &user)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn home(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let products = crate::controllers::catalog::featured_products(&ctx, 8).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"home/index.html",
|
||||
json!({
|
||||
"products": products,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login_page(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
if logged_in_admin(&ctx, &jar).await {
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": null,
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<LoginParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
return format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": "Invalid credentials",
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) {
|
||||
return format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": "Invalid credentials",
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
||||
.redirect("/admin/dashboard")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_logout() -> Result<Response> {
|
||||
format::render()
|
||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||
.redirect("/admin/login")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_home(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/index.html",
|
||||
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/", get(home))
|
||||
.add("/admin/login", get(admin_login_page))
|
||||
.add("/admin/login", post(admin_login))
|
||||
.add("/admin/logout", post(admin_logout))
|
||||
.add("/admin", get(admin_login_page))
|
||||
.add("/admin/dashboard", get(admin_home))
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod cart;
|
||||
pub mod catalog;
|
||||
pub mod frontend;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
pub mod orders;
|
||||
@@ -1,316 +0,0 @@
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin,
|
||||
cart::{resolve_cart, CART_COOKIE},
|
||||
catalog::format_price,
|
||||
i18n::current_lang,
|
||||
},
|
||||
models::_entities::{order_items, orders, products},
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CheckoutForm {
|
||||
email: String,
|
||||
customer_name: String,
|
||||
address: String,
|
||||
city: String,
|
||||
zip: String,
|
||||
country: String,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StatusForm {
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn trimmed(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn generate_order_number() -> String {
|
||||
let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase();
|
||||
format!("ORD-{suffix}")
|
||||
}
|
||||
|
||||
fn cleared_cart_cookie() -> Cookie<'static> {
|
||||
Cookie::build((CART_COOKIE, ""))
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn checkout_page(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let (lines, _valid, total) = resolve_cart(&ctx, &jar).await?;
|
||||
if lines.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
let currency = lines
|
||||
.first()
|
||||
.and_then(|line| line["currency"].as_str())
|
||||
.unwrap_or("EUR")
|
||||
.to_string();
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/checkout.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"total": format_price(total),
|
||||
"currency": currency,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn place_order(
|
||||
jar: CookieJar,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<CheckoutForm>,
|
||||
) -> Result<Response> {
|
||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
|
||||
if valid.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
let email = trimmed(&form.email)
|
||||
.ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
||||
|
||||
let txn = ctx.db.begin().await?;
|
||||
|
||||
// Snapshot prices/names and decrement stock atomically. Re-checking stock
|
||||
// inside the transaction guards against it selling out between cart and pay.
|
||||
let mut total: i64 = 0;
|
||||
let mut currency = "EUR".to_string();
|
||||
let mut snapshots = Vec::new();
|
||||
for (product_id, qty) in &valid {
|
||||
let product = products::Entity::find_by_id(*product_id)
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
||||
if product.stock < *qty {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"not enough stock for {}",
|
||||
product.name
|
||||
)));
|
||||
}
|
||||
currency = product.currency.clone();
|
||||
let line_total = product.price_cents * i64::from(*qty);
|
||||
total += line_total;
|
||||
|
||||
let mut active = product.clone().into_active_model();
|
||||
active.stock = Set(product.stock - *qty);
|
||||
active.update(&txn).await?;
|
||||
|
||||
snapshots.push((product.id, product.name, product.price_cents, *qty));
|
||||
}
|
||||
|
||||
let order = orders::ActiveModel {
|
||||
order_number: Set(generate_order_number()),
|
||||
email: Set(email),
|
||||
customer_name: Set(trimmed(&form.customer_name)),
|
||||
status: Set("pending".to_string()),
|
||||
total_cents: Set(total),
|
||||
currency: Set(currency),
|
||||
address: Set(trimmed(&form.address)),
|
||||
city: Set(trimmed(&form.city)),
|
||||
zip: Set(trimmed(&form.zip)),
|
||||
country: Set(trimmed(&form.country)),
|
||||
note: Set(form.note.as_deref().and_then(trimmed)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
for (product_id, name, unit_price_cents, qty) in snapshots {
|
||||
order_items::ActiveModel {
|
||||
order_id: Set(order.id),
|
||||
product_id: Set(Some(product_id)),
|
||||
product_name: Set(name),
|
||||
unit_price_cents: Set(unit_price_cents),
|
||||
quantity: Set(qty),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[cleared_cart_cookie()])?
|
||||
.redirect(&format!("/orders/{}", order.order_number))
|
||||
}
|
||||
|
||||
async fn order_with_items(
|
||||
ctx: &AppContext,
|
||||
order: &orders::Model,
|
||||
) -> Result<(serde_json::Value, Vec<serde_json::Value>)> {
|
||||
let items = order_items::Entity::find()
|
||||
.filter(order_items::Column::OrderId.eq(order.id))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let items_json = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
json!({
|
||||
"product_name": item.product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_price(item.unit_price_cents),
|
||||
"line_total": format_price(item.unit_price_cents * i64::from(item.quantity)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let order_json = json!({
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"email": order.email,
|
||||
"customer_name": order.customer_name,
|
||||
"status": order.status,
|
||||
"total": format_price(order.total_cents),
|
||||
"currency": order.currency,
|
||||
"address": order.address,
|
||||
"city": order.city,
|
||||
"zip": order.zip,
|
||||
"country": order.country,
|
||||
"note": order.note,
|
||||
"created_at": order.created_at.to_rfc3339(),
|
||||
});
|
||||
Ok((order_json, items_json))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn order_confirmation(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(order_number): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let order = orders::Entity::find()
|
||||
.filter(orders::Column::OrderNumber.eq(order_number))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let (order_json, items) = order_with_items(&ctx, &order).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/order_confirmed.html",
|
||||
json!({ "order": order_json, "items": items, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_orders(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = orders::Entity::find()
|
||||
.order_by_desc(orders::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let rows: Vec<serde_json::Value> = list
|
||||
.iter()
|
||||
.map(|order| {
|
||||
json!({
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"email": order.email,
|
||||
"status": order.status,
|
||||
"total": format_price(order.total_cents),
|
||||
"currency": order.currency,
|
||||
"created_at": order.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/index.html",
|
||||
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_order_show(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let (order_json, items) = order_with_items(&ctx, &order).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/show.html",
|
||||
json!({
|
||||
"order": order_json,
|
||||
"items": items,
|
||||
"statuses": ORDER_STATUSES,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_order_status(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<StatusForm>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
if !ORDER_STATUSES.contains(&form.status.as_str()) {
|
||||
return Err(Error::BadRequest("invalid status".to_string()));
|
||||
}
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let mut active = order.into_active_model();
|
||||
active.status = Set(form.status);
|
||||
active.update(&ctx.db).await?;
|
||||
|
||||
format::redirect(&format!("/admin/orders/{id}"))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/checkout", get(checkout_page))
|
||||
.add("/checkout", post(place_order))
|
||||
.add("/orders/{order_number}", get(order_confirmation))
|
||||
.add("/admin/orders", get(admin_orders))
|
||||
.add("/admin/orders/{id}", get(admin_order_show))
|
||||
.add("/admin/orders/{id}/status", post(admin_order_status))
|
||||
}
|
||||
30
src/home/mod.rs
Normal file
30
src/home/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! Public landing page.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{i18n::current_lang, shared::guard, shop};
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let products = shop::featured_products(&ctx, 8).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"home/index.html",
|
||||
json!({
|
||||
"products": products,
|
||||
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().add("/", get(index))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::models::users::{self, RegisterParams};
|
||||
use crate::account::models::users::{self, RegisterParams};
|
||||
|
||||
pub struct AdminSeeder;
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod admin_seeder;
|
||||
pub mod shipping_seeder;
|
||||
pub mod view_engine;
|
||||
|
||||
48
src/initializers/shipping_seeder.rs
Normal file
48
src/initializers/shipping_seeder.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use crate::models::_entities::shipping_methods;
|
||||
|
||||
/// (code, display name, price in cents, requires a pickup point)
|
||||
const CARRIERS: [(&str, &str, i64, bool); 3] = [
|
||||
("packeta", "Packeta", 300, true),
|
||||
("dpd", "DPD", 450, false),
|
||||
("dhl", "DHL", 500, false),
|
||||
];
|
||||
|
||||
pub struct ShippingSeeder;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ShippingSeeder {
|
||||
fn name(&self) -> String {
|
||||
"shipping-seeder".to_string()
|
||||
}
|
||||
|
||||
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||
for (position, (code, name, price_cents, requires_pickup_point)) in
|
||||
CARRIERS.iter().enumerate()
|
||||
{
|
||||
let exists = shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(*code))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.is_some();
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
shipping_methods::ActiveModel {
|
||||
code: Set((*code).to_string()),
|
||||
name: Set((*name).to_string()),
|
||||
price_cents: Set(*price_cents),
|
||||
requires_pickup_point: Set(*requires_pickup_point),
|
||||
enabled: Set(true),
|
||||
position: Set(position as i32),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
17
src/lib.rs
17
src/lib.rs
@@ -1,9 +1,22 @@
|
||||
pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod data;
|
||||
pub mod initializers;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
||||
|
||||
// Cross-cutting helpers shared by every feature.
|
||||
pub mod shared;
|
||||
|
||||
// Feature slices: each owns its routes, handlers, view-shaping and the model
|
||||
// methods/services specific to it. Generated sea-orm entities stay shared in
|
||||
// `models::_entities`.
|
||||
pub mod account;
|
||||
pub mod admin;
|
||||
pub mod cart;
|
||||
pub mod checkout;
|
||||
pub mod home;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
pub mod shop;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::users;
|
||||
use crate::account::models::users;
|
||||
|
||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::controllers::admin;
|
||||
use crate::shared::guard;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, Multipart},
|
||||
@@ -127,7 +127,7 @@ async fn image_upload(
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
|
||||
let extension = detect_image_extension(&data)?;
|
||||
let size = data.len();
|
||||
@@ -18,12 +18,21 @@ pub struct Model {
|
||||
pub image_id: Option<String>,
|
||||
pub position: i32,
|
||||
pub published: bool,
|
||||
pub parent_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::products::Entity")]
|
||||
Products,
|
||||
#[sea_orm(
|
||||
belongs_to = "Entity",
|
||||
from = "Column::ParentId",
|
||||
to = "Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "SetNull"
|
||||
)]
|
||||
Parent,
|
||||
}
|
||||
|
||||
impl Related<super::products::Entity> for Entity {
|
||||
|
||||
@@ -10,4 +10,5 @@ pub mod product_images;
|
||||
pub mod product_product_tags;
|
||||
pub mod product_tags;
|
||||
pub mod products;
|
||||
pub mod shipping_methods;
|
||||
pub mod users;
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct Model {
|
||||
pub country: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub note: Option<String>,
|
||||
pub payment_method: Option<String>,
|
||||
pub carrier_code: Option<String>,
|
||||
pub carrier_name: Option<String>,
|
||||
pub shipping_cents: i64,
|
||||
pub pickup_point_id: Option<String>,
|
||||
pub pickup_point_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -8,4 +8,5 @@ pub use super::product_images::Entity as ProductImages;
|
||||
pub use super::product_product_tags::Entity as ProductProductTags;
|
||||
pub use super::product_tags::Entity as ProductTags;
|
||||
pub use super::products::Entity as Products;
|
||||
pub use super::shipping_methods::Entity as ShippingMethods;
|
||||
pub use super::users::Entity as Users;
|
||||
|
||||
23
src/models/_entities/shipping_methods.rs
Normal file
23
src/models/_entities/shipping_methods.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "shipping_methods")]
|
||||
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 name: String,
|
||||
pub price_cents: i64,
|
||||
pub requires_pickup_point: bool,
|
||||
pub enabled: bool,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Shared data layer: the sea-orm entities generated by `loco generate`.
|
||||
//!
|
||||
//! These structs cross-reference each other (relations) and are regenerated as
|
||||
//! a unit, so they live here centrally. The hand-written model methods,
|
||||
//! services and view-shaping that use them live in the feature slices
|
||||
//! (`shop::models`, `checkout::models`, `account::models`, …).
|
||||
pub mod _entities;
|
||||
pub mod audit_logs;
|
||||
pub mod users;
|
||||
pub mod categories;
|
||||
pub mod products;
|
||||
pub mod product_images;
|
||||
pub mod product_tags;
|
||||
pub mod product_product_tags;
|
||||
pub mod orders;
|
||||
pub mod order_items;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::orders::{ActiveModel, Model, Entity};
|
||||
pub type Orders = 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 {}
|
||||
@@ -1,28 +0,0 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::product_images::{ActiveModel, Model, Entity};
|
||||
pub type ProductImages = 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 {}
|
||||
43
src/shared/guard.rs
Normal file
43
src/shared/guard.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Shared admin-authorization helpers used by both admin and public controllers.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::account::models::users;
|
||||
use crate::account::AUTH_COOKIE;
|
||||
use crate::shared::settings;
|
||||
|
||||
/// Is `user` the configured admin (settings.admin_email)?
|
||||
pub fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
settings::get(ctx, "admin_email")
|
||||
.is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
/// Guard for admin handlers: requires a valid JWT whose user matches the
|
||||
/// configured admin email. Returns the admin user, or an unauthorized error.
|
||||
pub async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
if !is_admin(ctx, &user) {
|
||||
return unauthorized("admin only");
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Soft check for public pages: does the request carry a valid admin auth
|
||||
/// cookie? Never errors — used only to decide whether to show admin chrome.
|
||||
pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||
let Some(cookie) = jar.get(AUTH_COOKIE) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
||||
return false;
|
||||
};
|
||||
is_admin(ctx, &user)
|
||||
}
|
||||
6
src/shared/mod.rs
Normal file
6
src/shared/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Cross-cutting helpers used across feature slices.
|
||||
|
||||
pub mod guard;
|
||||
pub mod money;
|
||||
pub mod settings;
|
||||
pub mod slug;
|
||||
36
src/shared/money.rs
Normal file
36
src/shared/money.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! Money helpers.
|
||||
//!
|
||||
//! Prices are stored throughout the app as integer **minor units** (cents).
|
||||
//! This module is the single place that converts between that storage form and
|
||||
//! the human-facing decimal strings shown in forms and templates.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
|
||||
/// minor units (cents). Rejects negatives and more than two decimals.
|
||||
pub fn parse_price_to_cents(value: &str) -> Result<i64> {
|
||||
let value = value.trim().replace(',', ".");
|
||||
let invalid = || Error::BadRequest("invalid price".to_string());
|
||||
let (whole, frac) = match value.split_once('.') {
|
||||
Some((w, f)) => (w, f),
|
||||
None => (value.as_str(), ""),
|
||||
};
|
||||
if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
if !frac.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let whole: i64 = whole.parse().map_err(|_| invalid())?;
|
||||
let cents: i64 = match frac.len() {
|
||||
0 => 0,
|
||||
1 => frac.parse::<i64>().map_err(|_| invalid())? * 10,
|
||||
_ => frac.parse().map_err(|_| invalid())?,
|
||||
};
|
||||
Ok(whole * 100 + cents)
|
||||
}
|
||||
|
||||
/// Render minor units as a human price string, e.g. `1234` -> `"12.34"`.
|
||||
pub fn format_price(cents: i64) -> String {
|
||||
format!("{}.{:02}", cents / 100, (cents % 100).abs())
|
||||
}
|
||||
13
src/shared/settings.rs
Normal file
13
src/shared/settings.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! Typed access to the free-form `settings.*` map from the loaded config.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// Look up a string-valued `settings.<key>` entry, returning `None` if config
|
||||
/// has no settings map, the key is missing, or the value is not a string.
|
||||
pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
}
|
||||
38
src/shared/slug.rs
Normal file
38
src/shared/slug.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! URL slug helpers shared by the catalog admin (products and categories).
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// Lowercase a string and collapse every run of non-alphanumeric characters
|
||||
/// into a single dash, trimming dashes from the ends.
|
||||
pub fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for ch in value.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// Find the first slug that does not already exist, appending `-2`, `-3`, … to
|
||||
/// `base` until `exists` reports the candidate as free. An empty `base` falls
|
||||
/// back to `"item"`.
|
||||
pub async fn unique_slug<F, Fut>(base: &str, mut exists: F) -> Result<String>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
let base = if base.is_empty() { "item" } else { base };
|
||||
let mut slug = base.to_string();
|
||||
let mut suffix = 2;
|
||||
while exists(slug.clone()).await? {
|
||||
slug = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
176
src/shop/mod.rs
Normal file
176
src/shop/mod.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
//! Public storefront: product listings, product detail, category pages and the
|
||||
//! lazily-loaded category sidebar.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||
use serde_json::json;
|
||||
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
i18n::current_lang,
|
||||
shared::guard,
|
||||
shop::models::{categories, product_images, products},
|
||||
};
|
||||
|
||||
/// Shape a list of products into card rows, loading each one's primary image.
|
||||
async fn product_rows(ctx: &AppContext, list: Vec<products::Model>) -> Result<Vec<serde_json::Value>> {
|
||||
let mut rows = Vec::with_capacity(list.len());
|
||||
for product in list {
|
||||
let image = product_images::first_for(ctx, product.id).await?;
|
||||
rows.push(view::product_card(&product, image, None));
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Latest published products (with primary image), shaped for templates. Reused
|
||||
/// by the home-page landing grid.
|
||||
pub(crate) async fn featured_products(
|
||||
ctx: &AppContext,
|
||||
limit: u64,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.limit(limit)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
product_rows(ctx, list).await
|
||||
}
|
||||
|
||||
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
|
||||
/// every page gets it without each handler having to supply category data.
|
||||
#[debug_handler]
|
||||
async fn category_sidebar(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let published = categories::published(&ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"shop/_sidebar.html",
|
||||
json!({
|
||||
"category_tree": view::sidebar_rows(&categories::tree(&published)),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": product_rows(&ctx, list).await?,
|
||||
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn show(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let product = products::Entity::find()
|
||||
.filter(products::Column::Slug.eq(slug))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let mut active = product.clone().into_active_model();
|
||||
active.view_count = Set(product.view_count + 1);
|
||||
let product = active.update(&ctx.db).await?;
|
||||
|
||||
let images = product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(product.id))
|
||||
.order_by_asc(product_images::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let category = match product.category_id {
|
||||
Some(id) => categories::Entity::find_by_id(id).one(&ctx.db).await?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/show.html",
|
||||
json!({
|
||||
"product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())),
|
||||
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
|
||||
"category": category,
|
||||
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn category(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let published = categories::published(&ctx).await?;
|
||||
let category = published
|
||||
.iter()
|
||||
.find(|c| c.slug == slug)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let breadcrumbs = categories::ancestors(&published, category.parent_id);
|
||||
let children = categories::children_of(&published, category.id);
|
||||
|
||||
// Products listed here span this category and all of its descendants, so a
|
||||
// parent category is never empty just because its products live in leaves.
|
||||
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id)
|
||||
.into_iter()
|
||||
.collect();
|
||||
category_ids.push(category.id);
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.is_in(category_ids))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/category.html",
|
||||
json!({
|
||||
"category": category,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"children": children,
|
||||
"products": product_rows(&ctx, list).await?,
|
||||
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/shop", get(index))
|
||||
.add("/shop/{slug}", get(show))
|
||||
.add("/category/{slug}", get(category))
|
||||
.add("/partials/categories", get(category_sidebar))
|
||||
}
|
||||
125
src/shop/models/categories.rs
Normal file
125
src/shop/models/categories.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
pub use crate::models::_entities::categories::{ActiveModel, Column, Entity, Model};
|
||||
pub type Categories = 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 write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every category, the source for tree building and validation.
|
||||
pub async fn all(ctx: &AppContext) -> Result<Vec<Model>> {
|
||||
Ok(Entity::find().all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
/// Only published categories, for the storefront.
|
||||
pub async fn published(ctx: &AppContext) -> Result<Vec<Model>> {
|
||||
Ok(Entity::find()
|
||||
.filter(Column::Published.eq(true))
|
||||
.all(&ctx.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hierarchy (adjacency list via `parent_id`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flatten the category forest into a depth-first ordered list of
|
||||
/// `(category, depth)`, sorting siblings by position then name. `depth` is 0
|
||||
/// for top-level categories and increases by one per level — templates use it
|
||||
/// to indent.
|
||||
pub fn tree(categories: &[Model]) -> Vec<(Model, usize)> {
|
||||
let mut children: HashMap<Option<i32>, Vec<&Model>> = HashMap::new();
|
||||
for category in categories {
|
||||
children.entry(category.parent_id).or_default().push(category);
|
||||
}
|
||||
for siblings in children.values_mut() {
|
||||
siblings.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||
}
|
||||
|
||||
fn walk(
|
||||
parent: Option<i32>,
|
||||
depth: usize,
|
||||
children: &HashMap<Option<i32>, Vec<&Model>>,
|
||||
out: &mut Vec<(Model, usize)>,
|
||||
) {
|
||||
if let Some(siblings) = children.get(&parent) {
|
||||
for category in siblings {
|
||||
out.push(((*category).clone(), depth));
|
||||
walk(Some(category.id), depth + 1, children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
walk(None, 0, &children, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Ids of every descendant of `root` (children, grandchildren, …), not
|
||||
/// including `root` itself.
|
||||
pub fn descendant_ids(categories: &[Model], root: i32) -> HashSet<i32> {
|
||||
let mut set = HashSet::new();
|
||||
let mut stack = vec![root];
|
||||
while let Some(id) = stack.pop() {
|
||||
for child in categories.iter().filter(|c| c.parent_id == Some(id)) {
|
||||
if set.insert(child.id) {
|
||||
stack.push(child.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Ancestor chain (root first … immediate parent last) for breadcrumbs.
|
||||
pub fn ancestors(categories: &[Model], start_parent: Option<i32>) -> Vec<Model> {
|
||||
let mut chain = Vec::new();
|
||||
let mut current = start_parent;
|
||||
while let Some(id) = current {
|
||||
match categories.iter().find(|c| c.id == id) {
|
||||
Some(category) => {
|
||||
current = category.parent_id;
|
||||
chain.push(category.clone());
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
/// Published direct children of `parent_id`, sorted for sub-navigation.
|
||||
pub fn children_of(categories: &[Model], parent_id: i32) -> Vec<Model> {
|
||||
let mut children: Vec<Model> = categories
|
||||
.iter()
|
||||
.filter(|c| c.parent_id == Some(parent_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
children.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||
children
|
||||
}
|
||||
5
src/shop/models/mod.rs
Normal file
5
src/shop/models/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod categories;
|
||||
pub mod product_images;
|
||||
pub mod product_product_tags;
|
||||
pub mod product_tags;
|
||||
pub mod products;
|
||||
47
src/shop/models/product_images.rs
Normal file
47
src/shop/models/product_images.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
|
||||
pub use crate::models::_entities::product_images::{ActiveModel, Column, Entity, Model};
|
||||
pub type ProductImages = 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 write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
|
||||
/// Filename of a product's primary (lowest-position) image, if any.
|
||||
pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
||||
Ok(Entity::find()
|
||||
.filter(Column::ProductId.eq(product_id))
|
||||
.order_by_asc(Column::Position)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|image| image.image_id))
|
||||
}
|
||||
|
||||
/// Number of images already attached to a product, used to position new uploads.
|
||||
pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result<i32> {
|
||||
use sea_orm::PaginatorTrait;
|
||||
Ok(Entity::find()
|
||||
.filter(Column::ProductId.eq(product_id))
|
||||
.count(&ctx.db)
|
||||
.await? as i32)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::product_product_tags::{ActiveModel, Model, Entity};
|
||||
pub use crate::models::_entities::product_product_tags::{ActiveModel, Model, Entity};
|
||||
pub type ProductProductTags = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -1,5 +1,5 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::product_tags::{ActiveModel, Model, Entity};
|
||||
pub use crate::models::_entities::product_tags::{ActiveModel, Model, Entity};
|
||||
pub type ProductTags = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -1,5 +1,5 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::products::{ActiveModel, Model, Entity};
|
||||
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
||||
pub type Products = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
56
src/shop/view.rs
Normal file
56
src/shop/view.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! JSON shaping for storefront and catalog-admin templates.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::models::_entities::{categories, products};
|
||||
use crate::shared::money::format_price;
|
||||
|
||||
/// Card/list shape for a product: model fields plus a formatted price, its
|
||||
/// optional primary image filename and category name.
|
||||
pub fn product_card(
|
||||
product: &products::Model,
|
||||
image: Option<String>,
|
||||
category_name: Option<String>,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": format_price(product.price_cents),
|
||||
"currency": product.currency,
|
||||
"sku": product.sku,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"image": image,
|
||||
"category_name": category_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shape used to pre-fill the admin product form (exposes `category_id` rather
|
||||
/// than a resolved name, and the current primary image).
|
||||
pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": format_price(product.price_cents),
|
||||
"currency": product.currency,
|
||||
"sku": product.sku,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"category_id": product.category_id,
|
||||
"image": image,
|
||||
})
|
||||
}
|
||||
|
||||
/// Depth-ordered `{ name, slug, depth }` rows for the storefront sidebar,
|
||||
/// rendered as an indented flat list.
|
||||
pub fn sidebar_rows(tree: &[(categories::Model, usize)]) -> Vec<Value> {
|
||||
tree.iter()
|
||||
.map(|(category, depth)| {
|
||||
json!({ "name": category.name, "slug": category.slug, "depth": depth })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod auth;
|
||||
@@ -5,4 +5,5 @@ mod products;
|
||||
mod product_images;
|
||||
mod product_tags;
|
||||
mod orders;
|
||||
mod order_items;
|
||||
mod order_items;
|
||||
mod shipping_methods;
|
||||
31
tests/models/shipping_methods.rs
Normal file
31
tests/models/shipping_methods.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use gitara_web::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_model() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>().await.unwrap();
|
||||
seed::<App>(&boot.app_context).await.unwrap();
|
||||
|
||||
// query your model, e.g.:
|
||||
//
|
||||
// let item = models::posts::Model::find_by_pid(
|
||||
// &boot.app_context.db,
|
||||
// "11111111-1111-1111-1111-111111111111",
|
||||
// )
|
||||
// .await;
|
||||
|
||||
// snapshot the result:
|
||||
// assert_debug_snapshot!(item);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use loco_rs::testing::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||
use serial_test::serial;
|
||||
use gitara_web::{
|
||||
account::models::users::{self, Model, RegisterParams},
|
||||
app::App,
|
||||
models::users::{self, Model, RegisterParams},
|
||||
};
|
||||
|
||||
macro_rules! configure_insta {
|
||||
|
||||
@@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings};
|
||||
use loco_rs::testing::prelude::*;
|
||||
use rstest::rstest;
|
||||
use serial_test::serial;
|
||||
use gitara_web::{app::App, models::users};
|
||||
use gitara_web::{account::models::users, app::App};
|
||||
|
||||
use super::prepare_data;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use loco_rs::{app::AppContext, TestServer};
|
||||
use gitara_web::{models::users, views::auth::LoginResponse};
|
||||
use gitara_web::{account::models::users, account::view::LoginResponse};
|
||||
|
||||
const USER_EMAIL: &str = "test@loco.com";
|
||||
const USER_PASSWORD: &str = "1234";
|
||||
|
||||
Reference in New Issue
Block a user