8 Commits

Author SHA1 Message Date
Priec
43562e964a subcategories implemented and working now 2026-06-17 15:11:21 +02:00
Priec
f54fd3d717 editing product now works well 2026-06-17 14:19:43 +02:00
Priec
e4f63b3de9 kompress eshop rename from gitara 2026-06-17 13:59:21 +02:00
Priec
95f195a204 initial seed 2026-06-17 13:40:21 +02:00
Priec
b88c990873 loco straucture 2026-06-17 09:58:36 +02:00
Priec
9ce07e8c23 better project structure
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-16 22:48:10 +02:00
Priec
b255e95051 sidebar and shit 2026-06-16 22:02:07 +02:00
Priec
f0a6f97609 more webshop 2026-06-16 19:27:29 +02:00
90 changed files with 3092 additions and 1523 deletions

View File

@@ -2,7 +2,7 @@ CONTAINER_NAME=universal-web
REVERSE_PROXY_NETWORK= REVERSE_PROXY_NETWORK=
UPLOADS_VOLUME_NAME=universal_web_uploads UPLOADS_VOLUME_NAME=universal_web_uploads
APP_HOST=https://gitara.farmeris.sk APP_HOST=https://eshop.example.com
PORT=5150 PORT=5150
SERVER_BINDING=0.0.0.0 SERVER_BINDING=0.0.0.0

View File

@@ -1,4 +1,4 @@
gitara.farmeris.sk { eshop.example.com {
encode gzip encode gzip
@static path /static/* @static path /static/*
@@ -6,9 +6,9 @@ gitara.farmeris.sk {
rewrite /favicon.ico /static/favicon/favicon.ico rewrite /favicon.ico /static/favicon/favicon.ico
reverse_proxy gitara-web:5150 reverse_proxy kompress:5150
} }
www.gitara.farmeris.sk { eshop.example.com {
redir https://gitara.farmeris.sk{uri} permanent redir https://eshop.example.com{uri} permanent
} }

60
Cargo.lock generated
View File

@@ -1523,36 +1523,6 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gitara_web"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"axum-extra",
"bytes",
"chrono",
"dotenvy",
"fluent-templates",
"include_dir",
"insta",
"loco-rs",
"migration",
"regex",
"rstest",
"sea-orm",
"serde",
"serde_json",
"serial_test",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"unic-langid",
"uuid",
"validator",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -2124,6 +2094,36 @@ dependencies = [
"simple_asn1", "simple_asn1",
] ]
[[package]]
name = "kompress_eshop"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"axum-extra",
"bytes",
"chrono",
"dotenvy",
"fluent-templates",
"include_dir",
"insta",
"loco-rs",
"migration",
"regex",
"rstest",
"sea-orm",
"serde",
"serde_json",
"serial_test",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"unic-langid",
"uuid",
"validator",
]
[[package]] [[package]]
name = "kqueue" name = "kqueue"
version = "1.1.1" version = "1.1.1"

View File

@@ -1,11 +1,11 @@
[workspace] [workspace]
[package] [package]
name = "gitara_web" name = "kompress_eshop"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
publish = false publish = false
default-run = "gitara_web-cli" default-run = "kompress-eshop-cli"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -45,7 +45,7 @@ axum-extra = { version = "0.10", features = ["form"] }
bytes = { version = "1" } bytes = { version = "1" }
[[bin]] [[bin]]
name = "gitara_web-cli" name = "kompress-eshop-cli"
path = "src/bin/main.rs" path = "src/bin/main.rs"
required-features = [] required-features = []

View File

@@ -5,7 +5,7 @@ WORKDIR /usr/src
COPY . . COPY . .
RUN cargo build --release --bin gitara_web-cli RUN cargo build --release --bin kompress-cli
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -17,9 +17,9 @@ WORKDIR /usr/app
COPY --from=builder /usr/src/assets assets COPY --from=builder /usr/src/assets assets
COPY --from=builder /usr/src/config config COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/gitara_web-cli gitara_web-cli COPY --from=builder /usr/src/target/release/kompress-cli kompress-cli
ENV LOCO_ENV=production ENV LOCO_ENV=production
EXPOSE 5150 EXPOSE 5150
ENTRYPOINT ["/usr/app/gitara_web-cli"] ENTRYPOINT ["/usr/app/kompress-cli"]
CMD ["start"] CMD ["start"]

View File

@@ -1,6 +1,6 @@
brand = My guitar brand = Kompress eshop
hello-world = Hello world! hello-world = Hello world!
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place. meta-description = Kompress eshop
nav-home = Home nav-home = Home
nav-about = About nav-about = About
nav-blog = Blog nav-blog = Blog

View File

@@ -1,6 +1,6 @@
brand = My guitar brand = Kompress eshop
hello-world = Hello world! hello-world = Hello world!
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place. meta-description = Kompress eshop
nav-home = Home nav-home = Home
nav-about = About nav-about = About
nav-blog = Blog nav-blog = Blog
@@ -198,6 +198,8 @@ image = Image
slug = URL slug slug = URL slug
slug-auto = generated automatically slug-auto = generated automatically
position = Position position = Position
parent-category = Parent category
no-parent = — None (top level) —
quantity = Quantity quantity = Quantity
add-to-cart = Add to cart add-to-cart = Add to cart
in-stock = In stock in-stock = In stock
@@ -206,6 +208,8 @@ confirm-delete = Delete this for good?
shop-title = Shop shop-title = Shop
shop-subtitle = browse our products. shop-subtitle = browse our products.
shop-empty = There are no products here yet. shop-empty = There are no products here yet.
categories = Categories
all-products = All products
cart-title = Cart cart-title = Cart
cart-empty = Your cart is empty. cart-empty = Your cart is empty.
cart-total = Total cart-total = Total
@@ -239,3 +243,23 @@ order-status-paid = Paid
order-status-shipped = Shipped order-status-shipped = Shipped
order-status-cancelled = Cancelled order-status-cancelled = Cancelled
order-update-status = Update status 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

View File

@@ -1,6 +1,6 @@
brand = Moja gitara brand = Kompress eshop
hello-world = Ahoj svet! hello-world = Ahoj svet!
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste. meta-description = Kompress eshop
nav-home = Domov nav-home = Domov
nav-about = O mne nav-about = O mne
nav-blog = Blog nav-blog = Blog
@@ -198,6 +198,8 @@ image = Obrázok
slug = URL adresa slug = URL adresa
slug-auto = vygeneruje sa automaticky slug-auto = vygeneruje sa automaticky
position = Poradie position = Poradie
parent-category = Nadradená kategória
no-parent = — Žiadna (najvyššia úroveň) —
quantity = Množstvo quantity = Množstvo
add-to-cart = Pridať do košíka add-to-cart = Pridať do košíka
in-stock = Na sklade in-stock = Na sklade
@@ -206,6 +208,8 @@ confirm-delete = Naozaj zmazať?
shop-title = Obchod shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov. shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty. shop-empty = Zatiaľ tu nie sú žiadne produkty.
categories = Kategórie
all-products = Všetky produkty
cart-title = Košík cart-title = Košík
cart-empty = Váš košík je prázdny. cart-empty = Váš košík je prázdny.
cart-total = Spolu cart-total = Spolu
@@ -239,3 +243,23 @@ order-status-paid = Zaplatené
order-status-shipped = Odoslané order-status-shipped = Odoslané
order-status-cancelled = Zrušené order-status-cancelled = Zrušené
order-update-status = Zmeniť stav 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

View File

@@ -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"> 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')) }} {{ t(key="admin-orders", lang=lang | default(value='sk')) }}
</a> </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>
<div class="border-t border-outline p-4 dark:border-outline-dark"> <div class="border-t border-outline p-4 dark:border-outline-dark">

View File

@@ -29,7 +29,12 @@
<tbody class="divide-y divide-outline dark:divide-outline-dark"> <tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for row in categories %} {% for row in categories %}
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt"> <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 tabular-nums">{{ row.product_count }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{% if row.category.published %} {% if row.category.published %}

View File

@@ -1,51 +1,63 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% set editing = category %} {% block title %}{% if category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block title %}{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %} {% block content %}
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong"> <h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %} {% if category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
</h1> </h1>
<a href="/admin/catalog/categories" <a href="/admin/catalog/categories"
class="inline-flex items-center rounded-radius border border-outline px-3 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="cancel", lang=lang | default(value='sk')) }}</a> class="inline-flex items-center rounded-radius border border-outline px-3 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="cancel", lang=lang | default(value='sk')) }}</a>
</div> </div>
<form method="post" enctype="multipart/form-data" <form method="post" enctype="multipart/form-data"
action="{% if editing %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}" action="{% if category %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label> <label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
<input id="name" name="name" type="text" required value="{% if editing %}{{ category.name }}{% endif %}" <input id="name" name="name" type="text" required value="{% if category %}{{ category.name }}{% endif %}"
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"> 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="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label> <label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
<input id="slug" name="slug" type="text" value="{% if editing %}{{ category.slug }}{% endif %}" <input id="slug" name="slug" type="text" value="{% if category %}{{ category.slug }}{% endif %}"
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}" placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
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"> 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"> <div class="space-y-1.5">
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label> <label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
<input id="position" name="position" type="number" value="{% if editing %}{{ category.position }}{% else %}0{% endif %}" <input id="position" name="position" type="number" value="{% if category %}{{ category.position }}{% else %}0{% endif %}"
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"> 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> </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 category and category.parent_id == parent.id %}selected{% endif %}>
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}—&nbsp;{% endfor %}{% endif %}{{ parent.name }}
</option>
{% endfor %}
</select>
</div>
<div class="space-y-1.5"> <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> <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" <textarea id="description" name="description" rows="4"
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">{% if editing and category.description %}{{ category.description }}{% endif %}</textarea> 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">{% if category and category.description %}{{ category.description }}{% endif %}</textarea>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label> <label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
{% if editing and category.image_id %} {% if category and category.image_id %}
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover"> <img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
{% endif %} {% endif %}
<input id="image" name="image" type="file" accept="image/*" <input id="image" name="image" type="file" accept="image/*"
@@ -53,7 +65,7 @@
</div> </div>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="published" value="on" {% if editing and category.published %}checked{% endif %} <input type="checkbox" name="published" value="on" {% if category and category.published %}checked{% endif %}
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark"> 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="published", lang=lang | default(value='sk')) }}</span> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label> </label>

View File

@@ -1,38 +1,37 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% set editing = product %} {% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block title %}{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %} {% block content %}
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong"> <h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %} {% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
</h1> </h1>
<a href="/admin/catalog/products" <a href="/admin/catalog/products"
class="inline-flex items-center rounded-radius border border-outline px-3 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="cancel", lang=lang | default(value='sk')) }}</a> class="inline-flex items-center rounded-radius border border-outline px-3 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="cancel", lang=lang | default(value='sk')) }}</a>
</div> </div>
<form method="post" enctype="multipart/form-data" <form method="post" enctype="multipart/form-data"
action="{% if editing %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}" action="{% if product %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label> <label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
<input id="name" name="name" type="text" required value="{% if editing %}{{ product.name }}{% endif %}" <input id="name" name="name" type="text" required value="{% if product %}{{ product.name }}{% endif %}"
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"> 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="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label for="price" 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" name="price" type="text" inputmode="decimal" required value="{% if editing %}{{ product.price }}{% endif %}" <input id="price" name="price" type="text" inputmode="decimal" required value="{% if product %}{{ product.price }}{% endif %}"
placeholder="0.00" placeholder="0.00"
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"> 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"> <div class="space-y-1.5">
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label> <label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
<input id="currency" name="currency" type="text" maxlength="3" value="{% if editing %}{{ product.currency }}{% else %}EUR{% endif %}" <input id="currency" name="currency" type="text" maxlength="3" value="{% if product %}{{ product.currency }}{% else %}EUR{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"> class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase 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> </div>
@@ -40,12 +39,12 @@
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label> <label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
<input id="stock" name="stock" type="number" min="0" value="{% if editing %}{{ product.stock }}{% else %}0{% endif %}" <input id="stock" name="stock" type="number" min="0" value="{% if product %}{{ product.stock }}{% else %}0{% endif %}"
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"> 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"> <div class="space-y-1.5">
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label> <label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
<input id="sku" name="sku" type="text" value="{% if editing and product.sku %}{{ product.sku }}{% endif %}" <input id="sku" name="sku" type="text" value="{% if product and product.sku %}{{ product.sku }}{% endif %}"
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"> 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> </div>
@@ -56,14 +55,14 @@
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"> 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-category", lang=lang | default(value='sk')) }}</option> <option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
{% for category in categories %} {% for category in categories %}
<option value="{{ category.id }}" {% if editing and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option> <option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label> <label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
<input id="slug" name="slug" type="text" value="{% if editing %}{{ product.slug }}{% endif %}" <input id="slug" name="slug" type="text" value="{% if product %}{{ product.slug }}{% endif %}"
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}" placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
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"> 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>
@@ -71,12 +70,12 @@
<div class="space-y-1.5"> <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> <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="5" <textarea id="description" name="description" rows="5"
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">{% if editing and product.description %}{{ product.description }}{% endif %}</textarea> 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">{% if product and product.description %}{{ product.description }}{% endif %}</textarea>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label> <label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
{% if editing and product.image %} {% if product and product.image %}
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover"> <img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
{% endif %} {% endif %}
<input id="image" name="image" type="file" accept="image/*" <input id="image" name="image" type="file" accept="image/*"
@@ -84,7 +83,7 @@
</div> </div>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="published" value="on" {% if editing and product.published %}checked{% endif %} <input type="checkbox" name="published" value="on" {% if product and product.published %}checked{% endif %}
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark"> 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="published", lang=lang | default(value='sk')) }}</span> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label> </label>

View File

@@ -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-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> <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>
<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 %} {% if order.note %}
<div> <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> <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>

View 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 %}

View File

@@ -45,10 +45,20 @@
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script> <script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
</head> </head>
<body hx-boost="true" <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 <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"> 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="/" <a href="/"
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong"> 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')) }} {{ t(key="brand", lang=lang | default(value='sk')) }}
@@ -169,8 +179,23 @@
</nav> </nav>
</header> </header>
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8"> <!-- dark overlay behind the category drawer on small screens -->
{% block content %}{% endblock content %} <div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
</main> 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> </body>
</html> </html>

View File

@@ -0,0 +1,60 @@
{# Site-wide category menu, served as an htmx partial and swapped into the
<aside> in base.html. `category_groups` is a two-level list of top-level
categories, each `{ name, slug, children: [{ name, slug }] }`. A category
with children is expandable (accordion); one without is a plain link.
Active state is set client-side by markActiveNav() via data-nav +
aria-current; groups auto-expand when the current page is the category or
one of its subcategories. #}
<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 group in category_groups %}
{% if group.children | length > 0 %}
<li x-data="{ open: false }"
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
<div class="flex items-stretch">
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
class="flex-1 truncate rounded-l-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">
{{ group.name }}
</a>
<button type="button" @click="open = !open" :aria-expanded="open"
aria-label="{{ group.name }}"
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-surface hover:text-primary dark:text-on-surface-dark/60 dark:hover:bg-surface-dark dark:hover:text-primary-dark">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
class="size-4 transition-transform" :class="open && 'rotate-90'">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
<ul x-show="open" x-cloak x-transition class="mt-0.5 flex flex-col gap-0.5">
{% for child in group.children %}
<li>
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}" style="padding-left: 28px"
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm 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">
<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>
<span class="truncate">{{ child.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li>
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
class="block truncate 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">
{{ group.name }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% if category_groups | 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 %}

View File

@@ -7,15 +7,28 @@
<header class="space-y-2"> <header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60"> <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> <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 class="px-1">/</span>
<span>{{ category.name }}</span> <span>{{ category.name }}</span>
</nav> </nav>
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1> <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 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> </header>
{% if products | length > 0 %} {% 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 %} {% for product in products %}
{% include "shop/_card.html" %} {% include "shop/_card.html" %}
{% endfor %} {% endfor %}

View File

@@ -3,10 +3,34 @@
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %} {% 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> <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"
<form method="post" action="/checkout" hx-boost="false" class="space-y-6 lg:col-span-2"> 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"> <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> <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"> <div class="space-y-1.5">
@@ -21,6 +45,7 @@
</div> </div>
</fieldset> </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"> <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> <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"> <div class="space-y-1.5">
@@ -41,23 +66,70 @@
</div> </div>
<div class="space-y-1.5"> <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> <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"> 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> </div>
<div class="space-y-1.5"> </fieldset>
<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" <!-- carrier -->
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 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> </div>
</fieldset> </fieldset>
<button type="submit" <!-- payment -->
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"> <fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }} <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>
</button> <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">
</form> <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"> <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> <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"> <ul class="space-y-2 text-sm">
@@ -68,10 +140,24 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </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"> <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>{{ 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> </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> </aside>
</div> </form>
{% endblock content %} {% endblock content %}

View File

@@ -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> <p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
</header> </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 %} {% 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 %} {% for product in products %}
{% include "shop/_card.html" %} {% include "shop/_card.html" %}
{% endfor %} {% endfor %}

View File

@@ -3,18 +3,18 @@
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %} {% block content %}
<div class="mx-auto max-w-2xl space-y-6 text-center"> <div class="mx-auto max-w-2xl space-y-6">
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success"> <div class="text-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7"> <div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
</svg> <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</div> </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> <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> <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>
<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"> <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="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> <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> </li>
{% endfor %} {% endfor %}
</ul> </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"> <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>{{ 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> <span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
</div> </div>
</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> </div>
{% endblock content %} {% endblock content %}

View File

@@ -71,7 +71,7 @@ mailer:
# Database Configuration # Database Configuration
database: database:
# Database connection URI # Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_development") }} uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_development") }}
# When enabled, the sql query will be logged. # When enabled, the sql query will be logged.
enable_logging: false enable_logging: false
# Set the timeout duration when acquiring a connection. # Set the timeout duration when acquiring a connection.
@@ -105,3 +105,9 @@ auth:
settings: settings:
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }} admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }} 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.") }}

View File

@@ -68,7 +68,7 @@ mailer:
# Database Configuration # Database Configuration
database: database:
# Database connection URI # Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_test") }} uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test") }}
# When enabled, the sql query will be logged. # When enabled, the sql query will be logged.
enable_logging: false enable_logging: false
# Set the timeout duration when acquiring a connection. # Set the timeout duration when acquiring a connection.

View File

@@ -1,6 +1,6 @@
services: services:
gitara-web: kompress:
container_name: gitara-web container_name: kompress
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -9,9 +9,9 @@ services:
env_file: env_file:
- .env.production - .env.production
volumes: volumes:
- gitara_web_data:/usr/app/data - kompress_eshop_data:/usr/app/data
networks: networks:
- gitara-net - kompress_eshop-net
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"] test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
@@ -21,10 +21,10 @@ services:
start_period: 20s start_period: 20s
networks: networks:
gitara-net: kompress_eshop-net:
external: true external: true
volumes: volumes:
gitara_web_data: kompress_eshop_data:
external: true external: true
name: gitara_web_data name: kompress_eshop_data

View File

@@ -1,6 +1,6 @@
#[allow(unused_imports)] #[allow(unused_imports)]
use loco_rs::{cli::playground, prelude::*}; use loco_rs::{cli::playground, prelude::*};
use gitara_web::app::App; use kompress_eshop::app::App;
#[tokio::main] #[tokio::main]
async fn main() -> loco_rs::Result<()> { async fn main() -> loco_rs::Result<()> {

View File

@@ -1,5 +1,5 @@
{ {
description = "Development Nix flake for the gitara_web (kompress_eshop) loco-rs app"; description = "Development Nix flake for the kompress (kompress_eshop) loco-rs app";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -41,8 +41,8 @@
cargo = pkgs.rust-bin.stable.latest.minimal; cargo = pkgs.rust-bin.stable.latest.minimal;
rustc = pkgs.rust-bin.stable.latest.minimal; rustc = pkgs.rust-bin.stable.latest.minimal;
}; };
gitara-web = rustPlatform.buildRustPackage { kompress = rustPlatform.buildRustPackage {
pname = "gitara_web"; pname = "kompress_eshop";
inherit version; inherit version;
src = ./.; src = ./.;
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
@@ -57,7 +57,7 @@
]; ];
# Build only the application binary. # Build only the application binary.
cargoBuildFlags = [ "--bin" "gitara_web-cli" ]; cargoBuildFlags = [ "--bin" "kompress-eshop-cli" ];
# Tests need a database/runtime environment; skip during the build. # Tests need a database/runtime environment; skip during the build.
doCheck = false; doCheck = false;
@@ -66,8 +66,8 @@
}; };
in in
{ {
gitara-web = gitara-web; kompress = kompress;
default = gitara-web; default = kompress;
} }
); );

View File

@@ -24,6 +24,9 @@ mod m20260616_130610_orders;
mod m20260616_130628_order_items; mod m20260616_130628_order_items;
mod m20260616_131000_drop_audio_tables; mod m20260616_131000_drop_audio_tables;
mod m20260616_132000_drop_blog_and_pages; 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; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -52,6 +55,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260616_130628_order_items::Migration), Box::new(m20260616_130628_order_items::Migration),
Box::new(m20260616_131000_drop_audio_tables::Migration), Box::new(m20260616_131000_drop_audio_tables::Migration),
Box::new(m20260616_132000_drop_blog_and_pages::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) // inject-above (do not remove this comment)
] ]
} }

View 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
}
}

View File

@@ -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(())
}
}

View 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
}
}

View File

@@ -16,7 +16,14 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)] #[allow(unused_imports)]
use crate::{ use crate::{
controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker, controllers::{
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders,
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop,
},
initializers,
models::_entities::users,
tasks,
workers::downloader::DownloadWorker,
}; };
pub struct App; pub struct App;
@@ -57,20 +64,28 @@ impl Hooks for App {
} }
fn routes(_ctx: &AppContext) -> AppRoutes { fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below AppRoutes::with_default_routes() // feature routes below
.add_route(controllers::auth::routes()) // public
.add_route(controllers::admin::routes()) .add_route(home::routes())
.add_route(controllers::catalog::routes()) .add_route(shop::routes())
.add_route(controllers::cart::routes()) .add_route(cart::routes())
.add_route(controllers::orders::routes()) .add_route(checkout::routes())
.add_route(controllers::i18n::routes()) // cross-cutting
.add_route(controllers::media::routes()) .add_route(auth::routes())
.add_route(controllers::frontend::routes()) .add_route(i18n::routes())
.add_route(media::routes())
// admin
.add_route(admin_dashboard::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> { async fn after_context(ctx: AppContext) -> Result<AppContext> {
let upload_root = crate::controllers::media::uploads_root(&ctx.config)?; let upload_root = media::uploads_root(&ctx.config)?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?; tokio::fs::create_dir_all(upload_root.join(media::IMAGE_STORAGE_DIR)).await?;
let driver = storage::drivers::local::new_with_prefix(&upload_root)?; let driver = storage::drivers::local::new_with_prefix(&upload_root)?;
Ok(AppContext { Ok(AppContext {
@@ -95,6 +110,7 @@ impl Hooks for App {
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> { async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string()) db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
.await?; .await?;
crate::seed::seed_catalog(ctx).await?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,6 +1,6 @@
use loco_rs::cli; use loco_rs::cli;
use migration::Migrator; use migration::Migrator;
use gitara_web::app::App; use kompress_eshop::app::App;
#[tokio::main] #[tokio::main]
async fn main() -> loco_rs::Result<()> { async fn main() -> loco_rs::Result<()> {

View File

@@ -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))
}

View File

@@ -0,0 +1,271 @@
//! 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::{
controllers::{
admin_form::{read_multipart_form, store_image, MultipartForm},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
shared::{
guard,
slug::{slugify, unique_slug},
},
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))
}

View File

@@ -0,0 +1,54 @@
//! Admin dashboard (HTML home + JSON stats).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, PaginatorTrait};
use serde::Serialize;
use serde_json::json;
use crate::{controllers::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))
}

View 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::controllers::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
}

View File

@@ -0,0 +1,86 @@
//! 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::{
controllers::auth as auth_controller,
models::users::{self, LoginParams},
controllers::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, &params.email).await else {
return login_error(&v, &jar);
};
if !user.verify_password(&params.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))
}

View File

@@ -0,0 +1,104 @@
//! 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::{
models::{order_items, orders},
views::checkout as view,
controllers::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))
}

View 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::{
controllers::{
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},
},
models::{categories, product_images, products},
views::shop as 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))
}

View 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::{
models::shipping_methods,
controllers::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))
}

View File

@@ -1,10 +1,8 @@
use crate::{ use crate::{
mailers::auth::AuthMailer, models::users::{self, LoginParams, RegisterParams},
models::{
_entities::users,
users::{LoginParams, RegisterParams},
},
views::auth::{CurrentResponse, LoginResponse}, views::auth::{CurrentResponse, LoginResponse},
mailers::auth::AuthMailer,
shared::guard::is_admin,
}; };
use axum_extra::extract::cookie::{Cookie, SameSite}; use axum_extra::extract::cookie::{Cookie, SameSite};
use loco_rs::prelude::*; use loco_rs::prelude::*;
@@ -22,18 +20,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> { pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, token.to_string())) Cookie::build((AUTH_COOKIE, token.to_string()))
.path("/") .path("/")

View File

@@ -1,7 +1,4 @@
use crate::{ use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
controllers::{catalog::format_price, i18n::current_lang},
models::_entities::products,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};

View File

@@ -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),
)
}

200
src/controllers/checkout.rs Normal file
View File

@@ -0,0 +1,200 @@
//! 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;
use crate::{
controllers::cart::{resolve_cart, CART_COOKIE},
models::{order_items, orders, shipping_methods},
controllers::i18n::current_lang,
shared::{money::format_price, settings},
views::checkout as view,
};
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,
orders::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))
}

View File

@@ -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, &params.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(&params.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))
}

30
src/controllers/home.rs Normal file
View File

@@ -0,0 +1,30 @@
//! Public landing page.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{controllers::i18n::current_lang, shared::guard, controllers::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))
}

View File

@@ -1,4 +1,4 @@
use crate::controllers::admin; use crate::shared::guard;
use axum::{ use axum::{
body::Body, body::Body,
extract::{DefaultBodyLimit, Multipart}, extract::{DefaultBodyLimit, Multipart},
@@ -127,7 +127,7 @@ async fn image_upload(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
multipart: Multipart, multipart: Multipart,
) -> Result<Response> { ) -> 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 data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
let extension = detect_image_extension(&data)?; let extension = detect_image_extension(&data)?;
let size = data.len(); let size = data.len();

View File

@@ -1,8 +1,14 @@
pub mod admin;
pub mod auth; pub mod auth;
pub mod admin_categories;
pub mod admin_dashboard;
pub mod admin_form;
pub mod admin_login;
pub mod admin_orders;
pub mod admin_products;
pub mod admin_shipping;
pub mod cart; pub mod cart;
pub mod catalog; pub mod checkout;
pub mod frontend; pub mod home;
pub mod i18n; pub mod i18n;
pub mod media; pub mod media;
pub mod orders; pub mod shop;

View File

@@ -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))
}

174
src/controllers/shop.rs Normal file
View File

@@ -0,0 +1,174 @@
//! 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;
use crate::{
controllers::i18n::current_lang,
shared::guard,
models::{categories, product_images, products},
views::shop as view,
};
/// 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_groups": view::sidebar_groups(&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))
}

View File

@@ -1,17 +1,19 @@
--- ---
- id: 1 - id: 2
pid: 11111111-1111-1111-1111-111111111111 pid: 11111111-1111-1111-1111-111111111111
email: user1@example.com email: user1@example.com
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758 api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
name: user1 name: user1
theme: light
created_at: "2023-11-12T12:34:56.789Z" created_at: "2023-11-12T12:34:56.789Z"
updated_at: "2023-11-12T12:34:56.789Z" updated_at: "2023-11-12T12:34:56.789Z"
- id: 2 - id: 3
pid: 22222222-2222-2222-2222-222222222222 pid: 22222222-2222-2222-2222-222222222222
email: user2@example.com email: user2@example.com
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc" password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
name: user2 name: user2
theme: light
created_at: "2023-11-12T12:34:56.789Z" created_at: "2023-11-12T12:34:56.789Z"
updated_at: "2023-11-12T12:34:56.789Z" updated_at: "2023-11-12T12:34:56.789Z"

View File

@@ -1,5 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use loco_rs::hash;
use sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
use crate::models::users::{self, RegisterParams}; use crate::models::users::{self, RegisterParams};
@@ -18,7 +20,19 @@ impl Initializer for AdminSeeder {
if email.is_empty() || password.is_empty() { if email.is_empty() || password.is_empty() {
tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env; admin not seeded"); tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env; admin not seeded");
} else if users::Model::find_by_email(&ctx.db, &email).await.is_err() { return Ok(());
}
if let Ok(user) = users::Model::find_by_email(&ctx.db, &email).await {
// User exists — update password so .env is always the source of truth.
let hash = hash::hash_password(&password)
.map_err(|e| Error::Message(e.to_string()))?;
let mut am = user.into_active_model();
am.password = Set(hash);
am.name = Set(name);
am.update(&ctx.db).await?;
tracing::info!(admin = %email, "admin password synced from .env");
} else {
users::Model::create_with_password( users::Model::create_with_password(
&ctx.db, &ctx.db,
&RegisterParams { &RegisterParams {

View File

@@ -4,6 +4,8 @@ pub mod data;
pub mod initializers; pub mod initializers;
pub mod mailers; pub mod mailers;
pub mod models; pub mod models;
pub mod seed;
pub mod shared;
pub mod tasks; pub mod tasks;
pub mod views; pub mod views;
pub mod workers; pub mod workers;

View File

@@ -18,12 +18,21 @@ pub struct Model {
pub image_id: Option<String>, pub image_id: Option<String>,
pub position: i32, pub position: i32,
pub published: bool, pub published: bool,
pub parent_id: Option<i32>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation { pub enum Relation {
#[sea_orm(has_many = "super::products::Entity")] #[sea_orm(has_many = "super::products::Entity")]
Products, 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 { impl Related<super::products::Entity> for Entity {

View File

@@ -10,4 +10,5 @@ pub mod product_images;
pub mod product_product_tags; pub mod product_product_tags;
pub mod product_tags; pub mod product_tags;
pub mod products; pub mod products;
pub mod shipping_methods;
pub mod users; pub mod users;

View File

@@ -23,6 +23,12 @@ pub struct Model {
pub country: Option<String>, pub country: Option<String>,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub note: Option<String>, 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)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -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_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags; pub use super::product_tags::Entity as ProductTags;
pub use super::products::Entity as Products; pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods;
pub use super::users::Entity as Users; pub use super::users::Entity as Users;

View 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 {}

View File

@@ -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::*; use sea_orm::entity::prelude::*;
pub type AuditLogs = Entity; pub type AuditLogs = Entity;

View File

@@ -1,5 +1,9 @@
use std::collections::{HashMap, HashSet};
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::categories::{ActiveModel, Model, Entity};
pub use crate::models::_entities::categories::{ActiveModel, Column, Entity, Model};
pub type Categories = Entity; pub type Categories = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -18,11 +22,104 @@ impl ActiveModelBehavior for ActiveModel {
} }
} }
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here // implement your write-oriented logic here
impl ActiveModel {} impl ActiveModel {}
// implement your custom finders, selectors oriented logic here // implement your custom finders, selectors oriented logic here
impl Entity {} 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
}

View File

@@ -1,10 +1,18 @@
//! Shared data layer: SeaORM entities and their hand-written model extensions.
//!
//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit).
//! The sibling files contain hand-written model impls: ActiveModelBehavior,
//! finder methods, business logic, and query helpers.
pub mod _entities; pub mod _entities;
pub mod audit_logs; pub mod audit_logs;
pub mod users;
pub mod categories; 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; pub mod order_items;
pub mod orders;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod products;
pub mod shipping_methods;
pub mod users;

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; 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; pub type OrderItems = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,7 +1,106 @@
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::orders::{ActiveModel, Model, Entity}; 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; 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] #[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel { impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr> async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>

View File

@@ -1,5 +1,8 @@
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::product_images::{ActiveModel, Model, Entity}; use sea_orm::QueryOrder;
pub use crate::models::_entities::product_images::{ActiveModel, Column, Entity, Model};
pub type ProductImages = Entity; pub type ProductImages = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -18,11 +21,27 @@ impl ActiveModelBehavior for ActiveModel {
} }
} }
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here // implement your write-oriented logic here
impl ActiveModel {} impl ActiveModel {}
// implement your custom finders, selectors oriented logic here // implement your custom finders, selectors oriented logic here
impl Entity {} 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)
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; 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; pub type ProductProductTags = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; 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; pub type ProductTags = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; 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; pub type Products = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::shipping_methods::{ActiveModel, Column, Entity, Model};
pub type ShippingMethods = 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 {}

View File

@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Map; use serde_json::Map;
use uuid::Uuid; 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_LENGTH: i8 = 32;
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
@@ -41,7 +41,7 @@ impl Validatable for ActiveModel {
} }
#[async_trait::async_trait] #[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> async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
where where
C: ConnectionTrait, C: ConnectionTrait,

178
src/seed.rs Normal file
View File

@@ -0,0 +1,178 @@
//! Catalog seed data — run via `cargo loco seed`.
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::{
models::_entities::{categories, products},
shared::slug::slugify,
};
// -- Categories -----------------------------------------------------------
struct CategorySeed {
name: &'static str,
description: &'static str,
position: i32,
}
const CATEGORIES: &[CategorySeed] = &[
CategorySeed {
name: "Electronics",
description: "Audio, computing, and smart devices",
position: 0,
},
CategorySeed {
name: "Accessories",
description: "Cables, adapters, cases, and everyday essentials",
position: 1,
},
CategorySeed {
name: "Home & Office",
description: "Ergonomic furniture, lighting, and desk organization",
position: 2,
},
];
// -- Products -------------------------------------------------------------
struct ProductSeed {
name: &'static str,
description: &'static str,
price_cents: i64,
stock: i32,
category_slug: &'static str,
sku: Option<&'static str>,
}
const PRODUCTS: &[ProductSeed] = &[
ProductSeed {
name: "Wireless Headphones",
description: "Over-ear Bluetooth headphones with active noise cancelling, 30-hour battery life, and plush memory-foam cushions.",
price_cents: 7_999,
stock: 25,
category_slug: "electronics",
sku: Some("WH-1000"),
},
ProductSeed {
name: "Mechanical Keyboard",
description: "Tenkeyless mechanical keyboard with hot-swappable switches, per-key RGB backlight, and a detachable USB-C cable.",
price_cents: 12_999,
stock: 15,
category_slug: "electronics",
sku: Some("MK-TKL-RGB"),
},
ProductSeed {
name: "USB-C Hub",
description: "7-in-1 USB-C hub with HDMI 4K output, 100 W power delivery pass-through, SD card reader, and three USB-A 3.2 ports.",
price_cents: 3_499,
stock: 40,
category_slug: "accessories",
sku: Some("USBC-HUB7"),
},
ProductSeed {
name: "Laptop Stand",
description: "Adjustable aluminium laptop stand with ventilated surface. Supports laptops from 10\u{201d} to 17\u{201d}.",
price_cents: 4_999,
stock: 30,
category_slug: "accessories",
sku: Some("LS-ALU-01"),
},
ProductSeed {
name: "Desk Lamp",
description: "LED desk lamp with 5 colour temperatures, stepless brightness control, and a flexible gooseneck arm.",
price_cents: 3_999,
stock: 20,
category_slug: "home-office",
sku: Some("DL-5CT"),
},
ProductSeed {
name: "Ergonomic Mouse",
description: "Vertical wireless ergonomic mouse with 6 buttons, adjustable DPI up to 4 000, and a sculpted thumb rest.",
price_cents: 5_999,
stock: 18,
category_slug: "electronics",
sku: Some("EM-VW-01"),
},
ProductSeed {
name: "Webcam Privacy Cover",
description: "Ultra-thin sliding webcam cover compatible with laptops, tablets, and external monitors. Pack of 3.",
price_cents: 599,
stock: 100,
category_slug: "accessories",
sku: Some("WPC-3PK"),
},
ProductSeed {
name: "Cable Organizer Set",
description: "Silicone cable management kit with 6 magnetic clips, 4 velcro straps, and an under-desk cable tray.",
price_cents: 1_299,
stock: 50,
category_slug: "home-office",
sku: Some("COS-MAG"),
},
];
// -- Public API -----------------------------------------------------------
/// Insert starter categories and products. Called from the `seed()` hook.
pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
for cat in CATEGORIES {
let slug = slugify(cat.name);
let exists = categories::Entity::find()
.filter(categories::Column::Slug.eq(&slug))
.one(&ctx.db)
.await?
.is_some();
if exists {
continue;
}
categories::ActiveModel {
name: Set(cat.name.to_string()),
slug: Set(slug),
description: Set(Some(cat.description.to_string())),
position: Set(cat.position),
published: Set(true),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
for item in PRODUCTS {
let product_slug = slugify(item.name);
let exists = products::Entity::find()
.filter(products::Column::Slug.eq(&product_slug))
.one(&ctx.db)
.await?
.is_some();
if exists {
continue;
}
let cat_slug = slugify(item.category_slug);
let category = categories::Entity::find()
.filter(categories::Column::Slug.eq(&cat_slug))
.one(&ctx.db)
.await?;
let now = chrono::Utc::now();
products::ActiveModel {
name: Set(item.name.to_string()),
slug: Set(product_slug),
description: Set(Some(item.description.to_string())),
price_cents: Set(item.price_cents),
currency: Set("EUR".to_string()),
sku: Set(item.sku.map(|s| s.to_string())),
stock: Set(item.stock),
published: Set(true),
published_at: Set(Some(now.into())),
category_id: Set(category.map(|c| c.id)),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
Ok(())
}

43
src/shared/guard.rs Normal file
View 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::models::users;
use crate::controllers::auth::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
View 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
View 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
View 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
View 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)
}

64
src/views/checkout.rs Normal file
View 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(),
})
}

View File

@@ -1 +1,5 @@
//! JSON view-shaping structs for API responses and templates.
pub mod auth; pub mod auth;
pub mod checkout;
pub mod shop;

68
src/views/shop.rs Normal file
View File

@@ -0,0 +1,68 @@
//! 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,
})
}
/// Two-level grouping for the storefront sidebar menu: each top-level category
/// as `{ name, slug, children: [{ name, slug }] }`, with its direct
/// subcategories nested under `children` (empty when the category has none).
/// Siblings are ordered by position then name on both levels.
pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
let mut top: Vec<&categories::Model> = categories
.iter()
.filter(|c| c.parent_id.is_none())
.collect();
top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
top.into_iter()
.map(|category| {
let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
.into_iter()
.map(|child| json!({ "name": child.name, "slug": child.slug }))
.collect();
json!({ "name": category.name, "slug": category.slug, "children": children })
})
.collect()
}

281
structure.md Normal file
View File

@@ -0,0 +1,281 @@
# Project Structure & How to Scale It
This is a [Loco](https://loco.rs) app (Rust, on top of Axum + SeaORM). It uses
the **standard Loco layer layout**. This document explains *why* that layout
scales and *how* you add things as the shop grows, so you never have to guess
where a new piece of code belongs.
---
## 1. The mental model: layers, not features
Loco organizes code by **what kind of thing it is** (a layer), not by which
feature it belongs to. The top-level dirs under `src/` are the layers:
```
src/
├── app.rs # The wiring hub: registers routes, workers, initializers, tasks
├── lib.rs # Declares which modules exist (pub mod ...)
├── bin/main.rs # Binary entrypoint (you rarely touch this)
├── controllers/ # HTTP layer: routes + request handlers
├── models/ # Data layer: DB entities + business logic
│ └── _entities/ # AUTO-GENERATED SeaORM structs — never hand-edit
├── views/ # Presentation layer: shapes data into JSON for templates
├── mailers/ # Email sending + email templates (.t files)
├── workers/ # Background jobs (async, off the request path)
├── tasks/ # CLI tasks (`cargo loco task ...`)
├── initializers/ # Runs once at boot (seeders, view engine setup, ...)
├── fixtures/ # Seed data (YAML) for `cargo loco db seed`
├── data/ # Misc static/loaded data
└── shared/ # Cross-cutting helpers used by many layers
```
Supporting dirs outside `src/`:
```
migration/ # SeaORM migrations (one file per schema change)
config/ # development.yaml / test.yaml / production.yaml
assets/ # Tera templates (views/), i18n (.ftl), static files, CSS
tests/ # requests/ models/ workers/ tasks/ + snapshot .snap files
```
### Why layers scale
The instinct is often "put everything for the shop in one folder." That feels
nice early, but it fights the framework: Loco's codegen, conventions, and docs
all assume layers. By staying with layers you get:
1. **`loco generate` just works.** Scaffolding lands in the right place; you
never hand-move files. (This is exactly why the project moved *back* to this
layout in the `loco straucture` commit.)
2. **Each layer has one reason to change.** A routing change touches only
`controllers/`. A schema change touches `migration/` + `models/_entities/`. A
"make the price display differently" change touches only `views/`. Bugs stay
contained.
3. **New contributors (and AI tools) navigate by convention**, not by reading
the whole tree.
The trade-off — "I have to open 3 dirs to see the whole shop feature" — is
solved below with naming, not folders.
---
## 2. Feature grouping without folders: the naming convention
You still get the "everything for X in one glance" benefit, via **filename
prefixes** inside the flat `controllers/` dir:
```
controllers/
├── home.rs ┐
├── shop.rs │ public storefront
├── cart.rs │
├── checkout.rs ┘
├── admin_dashboard.rs ┐
├── admin_products.rs │
├── admin_categories.rs│ admin area — `admin_` prefix groups them
├── admin_orders.rs │
├── admin_shipping.rs │
├── admin_login.rs │
├── admin_form.rs ┘
├── auth.rs ┐
├── i18n.rs │ cross-cutting
└── media.rs ┘
```
In your editor's file list, `admin_*` sorts together — you see the whole admin
surface at once, but `loco generate` and Loco conventions still see flat
controllers. Best of both.
**Rule of thumb:** prefix = the "feature area." Add `admin_returns.rs`, not a
`returns/` folder.
---
## 3. How a request flows through the layers
Trace the shop index (`GET /shop`) to see how layers cooperate — this is the
pattern every feature follows:
```
Browser → app.rs routes() # 1. router dispatches /shop to shop::index
→ controllers/shop.rs # 2. handler: query DB, gather data
→ models/products.rs # 3. data layer: products::Entity::find()...
→ views/shop.rs # 4. shape Model → JSON (product_card)
→ shared/guard.rs # 5. cross-cutting: is admin logged in?
→ assets/views/shop/index.html # 6. Tera renders the JSON
→ HTML response
```
Concretely, from `controllers/shop.rs`:
```rust
use crate::{
models::{categories, product_images, products}, // data layer
views::shop as view, // presentation layer
shared::guard, // cross-cutting
controllers::i18n::current_lang,
};
async fn index(...) -> Result<Response> {
let list = products::Entity::find() // query (models)
.filter(products::Column::Published.eq(true))
.all(&ctx.db).await?;
format::view(&v, "shop/index.html", json!({ // render
"products": product_rows(&ctx, list).await?, // shaped by views::shop
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}))
}
```
The controller is a thin coordinator. It does **not** contain business logic —
that lives in `models/`. It does **not** build HTML strings — that's `views/` +
templates. Keeping controllers thin is the single biggest factor in whether this
stays scalable.
---
## 4. The models layer: the one piece of Loco that surprises people
There are **two files per database table**, and they have different jobs:
```
models/
├── _entities/products.rs ← AUTO-GENERATED. The raw table struct
│ (columns, relations). Regenerated as a unit
│ whenever the schema changes. NEVER hand-edit.
└── products.rs ← HAND-WRITTEN. Re-exports the entity, then adds
your behavior on top of it.
```
Your `models/products.rs` shows the pattern exactly:
```rust
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
// lifecycle hooks, e.g. touch updated_at before save
}
impl Model {} // read-oriented logic (your finders that return data)
impl ActiveModel {} // write-oriented logic (validation, mutation)
impl Entity {} // custom queries / selectors
```
**Why two files:** the schema is machine-owned (so codegen can overwrite
`_entities/` safely), but your logic is human-owned (so it survives
regeneration). The `pub use` bridge means the rest of the app imports
`crate::models::products` and never has to know `_entities` exists.
**How to apply when scaling:** put domain logic on the model, not in the
controller. "Is this product low on stock?" → a method on `products::Model`.
"Recalculate order total" → a method on `orders`. As features pile up, this is
what keeps controllers from turning back into the 900-line god-files this
project deliberately escaped.
---
## 5. Where new code goes — a decision table
When you build the next thing, find the row and follow it:
| You want to add... | Touch these |
|---------------------------------------------|------------------------------------------------------------------------|
| A new page / endpoint | `controllers/<area>.rs` (+ register `routes()` in `app.rs`) |
| A new admin screen | `controllers/admin_<thing>.rs` (prefix!) + `assets/views/admin/...` |
| A new database table | `cargo loco generate model <name> ...` → migration + `_entities` + wrapper |
| A schema change to an existing table | `cargo loco generate migration <name> ...`, then rebuild & migrate |
| Business logic / a custom query | a method in `models/<entity>.rs` (not the controller) |
| Reshaping data for a template | `views/<area>.rs` |
| An HTML template / partial | `assets/views/<area>/...html` |
| A reusable helper (money, slugs, auth) | `shared/<helper>.rs` |
| Something slow (resize image, send batch) | `workers/<name>.rs` (+ register in `app.rs` `connect_workers`) |
| A transactional email | `mailers/<name>.rs` + `mailers/<name>/<event>/{subject,html,text}.t` |
| One-time-at-boot setup / seeding | `initializers/<name>.rs` (+ register in `app.rs` `initializers`) |
| A CLI maintenance command | `tasks/<name>.rs` (+ register in `app.rs` `register_tasks`) |
| A cross-cutting config value | `shared/settings.rs` + `config/*.yaml` |
---
## 6. `app.rs` is the wiring hub — the one file you revisit constantly
Every new route, worker, initializer, and task is *registered* here. It's the
table of contents for the whole backend:
```rust
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.add_route(shop::routes()) // ← every new controller's routes()
.add_route(admin_products::routes())// gets one line here
// ...
}
async fn initializers(...) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![ /* AdminSeeder, ShippingSeeder, ViewEngine ... */ ])
}
async fn connect_workers(...) { queue.register(DownloadWorker::build(ctx)).await?; }
fn register_tasks(tasks: &mut Tasks) { /* tasks-inject */ }
```
If you add a controller and the route 404s, the usual cause is: **you forgot the
`add_route` line in `app.rs`.** Same shape for workers/initializers/tasks.
---
## 7. Scaling checklist (the habits that keep this healthy)
As the shop grows, these are the things that decide whether the codebase stays
pleasant or rots:
1. **Keep controllers thin.** They query, gather, and render. Logic goes to
`models/`, shaping goes to `views/`. If a handler exceeds ~80 lines, extract.
2. **One controller file per feature area, prefix-grouped.** Don't let
`admin_products.rs` start handling orders. Split by area, not convenience.
3. **Never edit `models/_entities/`.** Change the schema via a migration and
regenerate. Your logic in the sibling wrapper survives.
4. **Push slow/optional work to `workers/`.** Image processing, bulk emails,
external API calls — off the request path so pages stay fast.
5. **Reuse via `shared/` and model methods**, not copy-paste. You already do
this well: `money` (integer cents everywhere), `guard` (one source of truth
for admin auth), `slug`.
6. **Every schema change is a migration file**, never a manual DB edit — so
`test`, `development`, and `production` stay reproducible from
`config/*.yaml` + `migration/`.
7. **Mirror new code with a test** in `tests/{models,requests,...}/`. The
snapshot tests (`.snap`) catch accidental output changes for free.
### When a feature genuinely outgrows a single file
If one area gets huge (say `shop` becomes 5+ concerns), you have two
Loco-friendly options — both keep the layout intact:
- **Split by sub-area with more prefixes:** `shop_catalog.rs`,
`shop_search.rs`, `shop_reviews.rs`.
- **Promote a layer file to a folder module:** turn `controllers/shop.rs` into
`controllers/shop/mod.rs` + `controllers/shop/{listing,detail,search}.rs`.
Loco doesn't care; `mod.rs` just re-exports a `routes()`.
What you should *not* do is recreate top-level vertical slices (a `src/shop/`
holding its own controllers+models+views). That's the layout this project
already tried and reverted — it breaks `loco generate` and fights the framework.
---
## TL;DR
- **Layers, not features.** `controllers/ models/ views/ ...` is deliberate and
is what makes `loco generate` and Loco conventions work for you.
- **Group features by filename prefix** (`admin_*`) inside the flat layers.
- **Controllers are thin coordinators**; logic lives on models, shaping in views.
- **`_entities/` is machine-owned; the sibling model file is yours.**
- **`app.rs` registers everything** — add a line there for each new route/worker/task.
- **Scale by adding files within layers**, splitting busy files into more
prefixes or `mod.rs` folders — never by going back to vertical slices.

View File

@@ -1,4 +1,4 @@
use gitara_web::app::App; use kompress_eshop::app::App;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use serial_test::serial; use serial_test::serial;

View File

@@ -5,4 +5,5 @@ mod products;
mod product_images; mod product_images;
mod product_tags; mod product_tags;
mod orders; mod orders;
mod order_items; mod order_items;
mod shipping_methods;

View File

@@ -1,4 +1,4 @@
use gitara_web::app::App; use kompress_eshop::app::App;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use serial_test::serial; use serial_test::serial;

View File

@@ -1,4 +1,4 @@
use gitara_web::app::App; use kompress_eshop::app::App;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use serial_test::serial; use serial_test::serial;

View File

@@ -1,4 +1,4 @@
use gitara_web::app::App; use kompress_eshop::app::App;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use serial_test::serial; use serial_test::serial;

View File

@@ -1,4 +1,4 @@
use gitara_web::app::App; use kompress_eshop::app::App;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use serial_test::serial; use serial_test::serial;

View File

@@ -1,4 +1,4 @@
use gitara_web::app::App; use kompress_eshop::app::App;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use serial_test::serial; use serial_test::serial;

View File

@@ -0,0 +1,31 @@
use kompress_eshop::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);
}

View File

@@ -3,9 +3,9 @@ use insta::assert_debug_snapshot;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
use serial_test::serial; use serial_test::serial;
use gitara_web::{ use kompress_eshop::{
app::App,
models::users::{self, Model, RegisterParams}, models::users::{self, Model, RegisterParams},
app::App,
}; };
macro_rules! configure_insta { macro_rules! configure_insta {

View File

@@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings};
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use rstest::rstest; use rstest::rstest;
use serial_test::serial; use serial_test::serial;
use gitara_web::{app::App, models::users}; use kompress_eshop::{models::users, app::App};
use super::prepare_data; use super::prepare_data;

View File

@@ -1,6 +1,6 @@
use axum::http::{HeaderName, HeaderValue}; use axum::http::{HeaderName, HeaderValue};
use loco_rs::{app::AppContext, TestServer}; use loco_rs::{app::AppContext, TestServer};
use gitara_web::{models::users, views::auth::LoginResponse}; use kompress_eshop::{models::users, views::auth::LoginResponse};
const USER_EMAIL: &str = "test@loco.com"; const USER_EMAIL: &str = "test@loco.com";
const USER_PASSWORD: &str = "1234"; const USER_PASSWORD: &str = "1234";