4 Commits

Author SHA1 Message Date
Priec
2d2aa012ec multiple images in the edit product
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-22 18:20:50 +02:00
Priec
125be1798e muiltiple images in carousel 2026-06-22 17:40:55 +02:00
Priec
f724e9763f upload picture now working well 2026-06-22 16:56:14 +02:00
Priec
681c88f85d 0 is out of stock and nothing is available from now on
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-22 16:48:28 +02:00
19 changed files with 331 additions and 97 deletions

View File

@@ -213,6 +213,8 @@ variants-options = Variants / options
add-option = Add option add-option = Add option
option-label = Option label option-label = Option label
optional = optional optional = optional
stock-untracked-hint = Leave blank = available without stock tracking
available = Available
choose-option = Choose an option choose-option = Choose an option
from-price = from { $price } from-price = from { $price }
admin-discounts = Discounts admin-discounts = Discounts
@@ -283,6 +285,10 @@ currency = Currency
category = Category category = Category
no-category = No category no-category = No category
image = Image image = Image
images = Images
main-image = Main
gallery-hint = The first image is the main one. Drag to reorder, click ✕ to remove.
add-images = Add images
slug = URL slug slug = URL slug
slug-auto = generated automatically slug-auto = generated automatically
position = Position position = Position

View File

@@ -213,6 +213,8 @@ variants-options = Varianty / možnosti
add-option = Pridať možnosť add-option = Pridať možnosť
option-label = Označenie možnosti option-label = Označenie možnosti
optional = voliteľné optional = voliteľné
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
available = Dostupné
choose-option = Vyberte možnosť choose-option = Vyberte možnosť
from-price = od { $price } from-price = od { $price }
admin-discounts = Zľavy admin-discounts = Zľavy
@@ -283,6 +285,10 @@ currency = Mena
category = Kategória category = Kategória
no-category = Bez kategórie no-category = Bez kategórie
image = Obrázok image = Obrázok
images = Obrázky
main-image = Hlavný
gallery-hint = Prvý obrázok je hlavný. Potiahnutím zmeníte poradie, krížikom obrázok odstránite.
add-images = Pridať obrázky
slug = URL adresa slug = URL adresa
slug-auto = vygeneruje sa automaticky slug-auto = vygeneruje sa automaticky
position = Poradie position = Poradie

File diff suppressed because one or more lines are too long

View File

@@ -37,10 +37,11 @@
{# --- Variants / options editor ------------------------------------------- #} {# --- Variants / options editor ------------------------------------------- #}
{# Each product is sold as one or more variants (a free-text label such as #} {# Each product is sold as one or more variants (a free-text label such as #}
{# "10cm x 13cm" or "5ml" plus its own price/stock, optional sku & business #} {# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
{# price). Price and stock are required; the browser blocks save if a row is #} {# optional — leave it blank ("∞") to mark the option simply available (not #}
{# incomplete. Rows are managed client-side; names are indexed (variants[i][…]) #} {# inventory-tracked). SKU and business price are optional too. Rows are #}
{# and read back by the controller. #} {# managed client-side; names are indexed (variants[i][…]) and read back by #}
{# the controller. #}
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %} {% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script> <script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))"> <div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
@@ -68,8 +69,8 @@
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}"> <input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
</div> </div>
<div class="space-y-1 sm:col-span-2"> <div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input type="number" min="0" required :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}"> <input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
</div> </div>
<div class="space-y-1 sm:col-span-2"> <div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
@@ -125,12 +126,77 @@
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }} {{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
</div> </div>
<div class="space-y-1.5"> {# --- Images gallery ------------------------------------------------------- #}
<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> {# Existing images are reorderable (drag) and removable; the kept set is #}
{% if product and product.image %} {# submitted in order as repeated `existing_images` ids. New uploads accumulate #}
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover"> {# across separate "Add images" clicks into a DataTransfer that backs the hidden #}
{% endif %} {# `image` input (a native file input would otherwise replace its selection on #}
{{ ui::file_input(name="image", id="image", accept="image/*") }} {# every pick); the controller stores and appends them after the kept images. #}
<script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
<div class="space-y-2" x-data="{
images: JSON.parse(document.getElementById('images-data').textContent),
staged: [],
dt: new DataTransfer(),
dragIndex: null,
onDrop(i) {
if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; }
this.images.splice(i, 0, this.images.splice(this.dragIndex, 1)[0]);
this.dragIndex = null;
},
addFiles(e) {
for (const f of e.target.files) { this.dt.items.add(f); this.staged.push({ url: URL.createObjectURL(f) }); }
this.$refs.holder.files = this.dt.files;
e.target.value = '';
},
removeStaged(i) {
this.dt.items.remove(i);
URL.revokeObjectURL(this.staged[i].url);
this.staged.splice(i, 1);
this.$refs.holder.files = this.dt.files;
},
}">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="images", lang=lang | default(value='sk')) }}</span>
<p class="{{ sublabel }}">{{ t(key="gallery-hint", lang=lang | default(value='sk')) }}</p>
<div class="flex flex-wrap gap-3" x-show="images.length || staged.length">
<template x-for="(im, i) in images" :key="im.id">
<div draggable="true"
@dragstart="dragIndex = i"
@dragover.prevent
@drop.prevent="onDrop(i)"
:class="dragIndex === i ? 'opacity-50' : ''"
class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<input type="hidden" name="existing_images" :value="im.id">
<img :src="`/images/${im.image_id}`" alt="" class="size-full object-cover">
<span x-show="i === 0"
class="absolute left-1 top-1 rounded-radius bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="main-image", lang=lang | default(value='sk')) }}</span>
<button type="button" @click="images.splice(i, 1)"
class="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-surface/70 text-xs text-danger opacity-0 transition group-hover:opacity-100 dark:bg-surface-dark/70"
title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div>
</template>
{# Newly staged uploads (not yet saved): previews + remove. #}
<template x-for="(f, i) in staged" :key="f.url">
<div class="group relative size-24 overflow-hidden rounded-radius border border-dashed border-outline dark:border-outline-dark">
<img :src="f.url" alt="" class="size-full object-cover">
<span x-show="images.length === 0 && i === 0"
class="absolute left-1 top-1 rounded-radius bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="main-image", lang=lang | default(value='sk')) }}</span>
<button type="button" @click="removeStaged(i)"
class="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-surface/70 text-xs text-danger opacity-0 transition group-hover:opacity-100 dark:bg-surface-dark/70"
title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div>
</template>
</div>
{# Hidden input carries the accumulated files on submit; the visible picker #}
{# only feeds addFiles() and is reset after each pick so selections stack. #}
<input type="file" name="image" multiple class="hidden" x-ref="holder">
<input type="file" accept="image/*" multiple class="hidden" x-ref="picker" @change="addFiles($event)">
<button type="button" @click="$refs.picker.click()"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
+ {{ t(key="add-images", lang=lang | default(value='sk')) }}
</button>
</div> </div>
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }} {{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}

View File

@@ -32,8 +32,8 @@
{% if product.has_options %} {% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #} {# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }} {{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
{% elif product.stock > 0 %} {% elif product.in_stock %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p> <p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" <form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')"> hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf" value="{{ csrf_token() }}">

View File

@@ -37,7 +37,7 @@
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML"> hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
<input type="hidden" name="variant_id" value="{{ item.id }}"> <input type="hidden" name="variant_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}" <input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
@change=" @change="
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) { if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
$el.value = '{{ item.quantity }}'; $el.value = '{{ item.quantity }}';

View File

@@ -98,7 +98,14 @@
</div> </div>
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button> <button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
</form> </form>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></p> <p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<template x-if="current.tracked">
<span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></span>
</template>
<template x-if="!current.tracked">
<span>{{ t(key="available", lang=lang | default(value='sk')) }}</span>
</template>
</p>
</div> </div>
</template> </template>
<template x-if="!current.in_stock"> <template x-if="!current.in_stock">

View File

@@ -41,6 +41,7 @@ mod m20260621_000003_discount_profiles;
mod m20260621_000004_add_business_sale_price_to_products; mod m20260621_000004_add_business_sale_price_to_products;
mod m20260622_000001_audience_discount_profiles; mod m20260622_000001_audience_discount_profiles;
mod m20260622_000002_product_variants; mod m20260622_000002_product_variants;
mod m20260622_000003_variant_stock_nullable;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -86,6 +87,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration), Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
Box::new(m20260622_000001_audience_discount_profiles::Migration), Box::new(m20260622_000001_audience_discount_profiles::Migration),
Box::new(m20260622_000002_product_variants::Migration), Box::new(m20260622_000002_product_variants::Migration),
Box::new(m20260622_000003_variant_stock_nullable::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,36 @@
//! Make `product_variants.stock` nullable: a NULL stock means the variant is
//! "available" but not inventory-tracked — always purchasable, no quantity cap,
//! and never decremented on order. A numeric stock is tracked/capped as before.
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> {
m.get_connection()
.execute_unprepared(
r#"
ALTER TABLE product_variants ALTER COLUMN stock DROP DEFAULT;
ALTER TABLE product_variants ALTER COLUMN stock DROP NOT NULL;
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
UPDATE product_variants SET stock = 0 WHERE stock IS NULL;
ALTER TABLE product_variants ALTER COLUMN stock SET DEFAULT 0;
ALTER TABLE product_variants ALTER COLUMN stock SET NOT NULL;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -198,7 +198,7 @@ async fn create(
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?; let form = read_multipart_form(multipart).await?;
let fields = parse_category_fields(&ctx, &form, None).await?; let fields = parse_category_fields(&ctx, &form, None).await?;
let image_id = match form.image { let image_id = match form.single_image() {
Some(data) => Some(store_image(&ctx, data).await?), Some(data) => Some(store_image(&ctx, data).await?),
None => None, None => None,
}; };
@@ -252,7 +252,7 @@ async fn update(
category.position = Set(fields.position); category.position = Set(fields.position);
category.published = Set(fields.published); category.published = Set(fields.published);
category.parent_id = Set(fields.parent_id); category.parent_id = Set(fields.parent_id);
if let Some(data) = form.image { if let Some(data) = form.single_image() {
category.image_id = Set(Some(store_image(&ctx, data).await?)); category.image_id = Set(Some(store_image(&ctx, data).await?));
} }
category.update(&ctx.db).await?; category.update(&ctx.db).await?;

View File

@@ -1,8 +1,10 @@
//! Multipart form handling shared by the product and category admin forms. //! 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; //! Both forms submit a mix of text fields and `image` file part(s); this
//! this collects them into an easy-to-query [`MultipartForm`] and stores any //! collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver. //! uploaded image through the configured storage driver. The product form can
//! upload several images at once and submits the ids of the existing images it
//! keeps (in display order) as repeated `existing_images` fields.
use std::collections::HashMap; use std::collections::HashMap;
@@ -18,11 +20,15 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
}) })
} }
/// Collected multipart form: text fields keyed by name, plus the raw bytes of /// Collected multipart form: text fields keyed by name, the raw bytes of every
/// an `image` file part if one was uploaded (an empty file input is ignored). /// `image` file part uploaded (empty file inputs are ignored, submission order
/// preserved), and the ids of the existing images the form kept, in the display
/// order it submitted them (`existing_images`, repeated — drives reorder and
/// delete on the edit form).
pub(crate) struct MultipartForm { pub(crate) struct MultipartForm {
fields: HashMap<String, String>, fields: HashMap<String, String>,
pub(crate) image: Option<Vec<u8>>, pub(crate) images: Vec<Vec<u8>>,
pub(crate) kept_image_ids: Vec<i32>,
} }
impl MultipartForm { impl MultipartForm {
@@ -31,6 +37,12 @@ impl MultipartForm {
normalize_empty(self.fields.get(key).cloned()) normalize_empty(self.fields.get(key).cloned())
} }
/// The single uploaded image, for forms (like categories) that accept only
/// one. Consumes the first uploaded part; any extras are ignored.
pub(crate) fn single_image(self) -> Option<Vec<u8>> {
self.images.into_iter().next()
}
/// Whether a checkbox-style field is checked. /// Whether a checkbox-style field is checked.
pub(crate) fn checked(&self, key: &str) -> bool { pub(crate) fn checked(&self, key: &str) -> bool {
matches!( matches!(
@@ -59,7 +71,8 @@ impl MultipartForm {
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> { pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
let mut fields = HashMap::new(); let mut fields = HashMap::new();
let mut image = None; let mut images = Vec::new();
let mut kept_image_ids = Vec::new();
while let Some(mut field) = multipart while let Some(mut field) = multipart
.next_field() .next_field()
@@ -82,8 +95,17 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
))); )));
} }
} }
// An empty file part (no file chosen in a slot) is ignored.
if !data.is_empty() { if !data.is_empty() {
image = Some(data); images.push(data);
}
} else if name == "existing_images" {
let value = field
.text()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
if let Ok(id) = value.trim().parse::<i32>() {
kept_image_ids.push(id);
} }
} else { } else {
let value = field let value = field
@@ -94,7 +116,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
} }
} }
Ok(MultipartForm { fields, image }) Ok(MultipartForm {
fields,
images,
kept_image_ids,
})
} }
/// Store an uploaded image's bytes and return its generated filename. /// Store an uploaded image's bytes and return its generated filename.

View File

@@ -102,7 +102,8 @@ struct VariantInput {
id: Option<i32>, id: Option<i32>,
label: String, label: String,
sku: Option<String>, sku: Option<String>,
stock: i32, /// `None` = available but not inventory-tracked.
stock: Option<i32>,
price_cents: i64, price_cents: i64,
business_sale_cents: Option<i64>, business_sale_cents: Option<i64>,
position: i32, position: i32,
@@ -156,11 +157,17 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
} }
let sku = form.text(&format!("variants[{i}][sku]")); let sku = form.text(&format!("variants[{i}][sku]"));
let stock = form // Stock is optional: blank means "available, not tracked". A value must
.text(&format!("variants[{i}][stock]")) // be a non-negative integer.
.and_then(|s| s.parse::<i32>().ok()) let stock = match form.text(&format!("variants[{i}][stock]")) {
.filter(|n| *n >= 0) None => None,
.ok_or_else(|| Error::BadRequest("each option needs a stock quantity".to_string()))?; Some(raw) => Some(
raw.parse::<i32>()
.ok()
.filter(|n| *n >= 0)
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
),
};
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?; let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
let id = form let id = form
.text(&format!("variants[{i}][id]")) .text(&format!("variants[{i}][id]"))
@@ -315,12 +322,22 @@ async fn index(
let category_name = product let category_name = product
.category_id .category_id
.and_then(|id| category_name.get(&id).cloned()); .and_then(|id| category_name.get(&id).cloned());
let total_stock: i32 = variants.iter().map(|v| v.stock).sum(); // Stock column: total across tracked variants, or "∞" when any option is
// untracked (always available).
let stock_display = if variants.iter().any(|v| !v.tracked()) {
"".to_string()
} else {
variants
.iter()
.filter_map(|v| v.stock)
.sum::<i32>()
.to_string()
};
rows.push(product_row( rows.push(product_row(
product, product,
priced, priced,
variants.len(), variants.len(),
total_stock, stock_display,
image, image,
category_name, category_name,
)); ));
@@ -349,7 +366,7 @@ fn product_row(
product: &products::Model, product: &products::Model,
effective: &pricing::PricedProduct, effective: &pricing::PricedProduct,
variant_count: usize, variant_count: usize,
total_stock: i32, stock_display: String,
image: Option<String>, image: Option<String>,
category_name: Option<String>, category_name: Option<String>,
) -> serde_json::Value { ) -> serde_json::Value {
@@ -358,7 +375,7 @@ fn product_row(
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"currency": product.currency, "currency": product.currency,
"stock": total_stock, "stock": stock_display,
"variant_count": variant_count, "variant_count": variant_count,
"has_options": variant_count > 1, "has_options": variant_count > 1,
"published": product.published, "published": product.published,
@@ -441,24 +458,62 @@ async fn create(
.insert(&txn) .insert(&txn)
.await?; .await?;
sync_variants(&txn, product.id, &variants).await?; sync_variants(&txn, product.id, &variants).await?;
sync_images(&ctx, &txn, product.id, &form.kept_image_ids, &form.images).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(&txn)
.await?;
}
txn.commit().await?; txn.commit().await?;
format::redirect("/admin/catalog/products") format::redirect("/admin/catalog/products")
} }
/// Reconcile a product's images inside `txn` with the submitted form: keep the
/// existing images named in `kept_ids`, re-numbering their positions to that
/// order (first = main); delete any existing image no longer named; then store
/// and append the freshly uploaded `new_images` after the kept ones.
async fn sync_images<C: ConnectionTrait>(
ctx: &AppContext,
txn: &C,
product_id: i32,
kept_ids: &[i32],
new_images: &[Vec<u8>],
) -> Result<()> {
let existing = product_images::for_product(txn, product_id).await?;
let by_id: HashMap<i32, product_images::Model> =
existing.iter().map(|m| (m.id, m.clone())).collect();
let keep: HashSet<i32> = kept_ids.iter().copied().collect();
for image in &existing {
if !keep.contains(&image.id) {
image.clone().delete(txn).await?;
}
}
// Re-number the kept images to their submitted order. Ids that no longer
// exist (a stale form) are simply skipped.
let mut position = 0i32;
for id in kept_ids {
if let Some(model) = by_id.get(id) {
let mut active = model.clone().into_active_model();
active.position = Set(position);
active.update(txn).await?;
position += 1;
}
}
for data in new_images {
let filename = store_image(ctx, data.clone()).await?;
product_images::ActiveModel {
product_id: Set(product_id),
image_id: Set(filename),
position: Set(position),
alt: Set(None),
..Default::default()
}
.insert(txn)
.await?;
position += 1;
}
Ok(())
}
#[debug_handler] #[debug_handler]
async fn edit( async fn edit(
auth: auth::JWT, auth: auth::JWT,
@@ -469,10 +524,10 @@ async fn edit(
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?; let product = product_by_id(&ctx, id).await?;
let image = product_images::first_for(&ctx, id).await?; let images = product_images::for_product(&ctx.db, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?; let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let mut context = form_context(&ctx, &jar).await?; let mut context = form_context(&ctx, &jar).await?;
context["product"] = view::product_form(&product, image); context["product"] = view::product_form(&product, &images);
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>()); context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
format::view(&v, "admin/catalog/product_form.html", context) format::view(&v, "admin/catalog/product_form.html", context)
} }
@@ -506,20 +561,7 @@ async fn update(
} }
product.update(&txn).await?; product.update(&txn).await?;
sync_variants(&txn, id, &variants).await?; sync_variants(&txn, id, &variants).await?;
sync_images(&ctx, &txn, id, &form.kept_image_ids, &form.images).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(&txn)
.await?;
}
txn.commit().await?; txn.commit().await?;
format::redirect("/admin/catalog/products") format::redirect("/admin/catalog/products")
@@ -682,7 +724,9 @@ async fn sync_profiles(
} }
pub fn routes() -> Routes { pub fn routes() -> Routes {
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024); // Several images may be uploaded in one submission; allow a generous total
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES * 12 + 1024 * 1024);
Routes::new() Routes::new()
.add("/admin/catalog/products", get(index)) .add("/admin/catalog/products", get(index))
.add("/admin/catalog/products/new", get(new)) .add("/admin/catalog/products/new", get(new))

View File

@@ -97,9 +97,9 @@ async fn add(
let mut items = parse_cart(&jar); let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1); let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) { if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = (entry.1 + add_qty).min(variant.stock); entry.1 = variant.cap(entry.1 + add_qty);
} else { } else {
items.push((variant.id, add_qty.min(variant.stock))); items.push((variant.id, variant.cap(add_qty)));
} }
items.retain(|(_, qty)| *qty > 0); items.retain(|(_, qty)| *qty > 0);
@@ -128,13 +128,14 @@ async fn update(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<UpdateForm>, Form(form): Form<UpdateForm>,
) -> Result<Response> { ) -> Result<Response> {
let stock = published_variant(&ctx, form.variant_id) // Clamp the requested quantity to what's available (no cap for untracked
.await? // variants); a removed variant clamps to 0 and drops out below.
.map(|(v, _)| v.stock) let clamped = match published_variant(&ctx, form.variant_id).await? {
.unwrap_or(0); Some((variant, _)) => variant.cap(form.quantity),
None => 0,
};
let mut items = parse_cart(&jar); let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) { if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped; entry.1 = clamped;
} }
@@ -208,7 +209,7 @@ pub(crate) async fn resolve_cart(
let Some((variant, product)) = published_variant(ctx, id).await? else { let Some((variant, product)) = published_variant(ctx, id).await? else {
continue; continue;
}; };
let qty = qty.clamp(0, variant.stock); let qty = variant.cap(qty);
if qty == 0 { if qty == 0 {
continue; continue;
} }

View File

@@ -14,7 +14,7 @@ pub struct Model {
pub label: String, pub label: String,
pub position: i32, pub position: i32,
pub sku: Option<String>, pub sku: Option<String>,
pub stock: i32, pub stock: Option<i32>,
pub price_cents: i64, pub price_cents: i64,
pub sale_price_cents: Option<i64>, pub sale_price_cents: Option<i64>,
pub business_sale_price_cents: Option<i64>, pub business_sale_price_cents: Option<i64>,

View File

@@ -65,11 +65,15 @@ pub async fn place(
.one(&txn) .one(&txn)
.await? .await?
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?; .ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
if variant.stock < *qty { // Tracked variants can't oversell; untracked ones (stock = None) are
return Err(Error::BadRequest(format!( // always available and never decremented.
"not enough stock for {}", if let Some(on_hand) = variant.stock {
product.name if on_hand < *qty {
))); return Err(Error::BadRequest(format!(
"not enough stock for {}",
product.name
)));
}
} }
currency = product.currency.clone(); currency = product.currency.clone();
// Snapshot the price the buyer actually pays — public sale or, for a // Snapshot the price the buyer actually pays — public sale or, for a
@@ -78,9 +82,11 @@ pub async fn place(
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents; let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
subtotal += unit_price_cents * i64::from(*qty); subtotal += unit_price_cents * i64::from(*qty);
let mut active = variant.clone().into_active_model(); if let Some(on_hand) = variant.stock {
active.stock = Set(variant.stock - *qty); let mut active = variant.clone().into_active_model();
active.update(&txn).await?; active.stock = Set(Some(on_hand - *qty));
active.update(&txn).await?;
}
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty)); snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
} }

View File

@@ -37,11 +37,15 @@ pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result<Option<Strin
.map(|image| image.image_id)) .map(|image| image.image_id))
} }
/// Number of images already attached to a product, used to position new uploads. /// All of a product's images in display order (lowest position first). Takes a
pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result<i32> { /// connection so it can run inside the update transaction.
use sea_orm::PaginatorTrait; pub async fn for_product<C: ConnectionTrait>(
db: &C,
product_id: i32,
) -> Result<Vec<Model>> {
Ok(Entity::find() Ok(Entity::find()
.filter(Column::ProductId.eq(product_id)) .filter(Column::ProductId.eq(product_id))
.count(&ctx.db) .order_by_asc(Column::Position)
.await? as i32) .all(db)
.await?)
} }

View File

@@ -45,6 +45,30 @@ impl Model {
pub fn business_on_sale(&self) -> bool { pub fn business_on_sale(&self) -> bool {
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents) matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
} }
/// Whether the variant's inventory is tracked. A `None` stock means
/// "available, not tracked" (always purchasable, unlimited).
#[must_use]
pub fn tracked(&self) -> bool {
self.stock.is_some()
}
/// Whether the variant can currently be bought: untracked variants are always
/// available; tracked ones need a positive quantity on hand.
#[must_use]
pub fn in_stock(&self) -> bool {
self.stock.map_or(true, |s| s > 0)
}
/// Clamp a desired quantity to what's available: capped at the tracked stock,
/// or left as-is (only floored at 0) when untracked.
#[must_use]
pub fn cap(&self, qty: i32) -> i32 {
match self.stock {
Some(s) => qty.clamp(0, s),
None => qty.max(0),
}
}
} }
// implement your write-oriented logic here // implement your write-oriented logic here

View File

@@ -177,7 +177,7 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
label: Set(String::new()), label: Set(String::new()),
position: Set(0), position: Set(0),
sku: Set(item.sku.map(|s| s.to_string())), sku: Set(item.sku.map(|s| s.to_string())),
stock: Set(item.stock), stock: Set(Some(item.stock)),
price_cents: Set(item.price_cents), price_cents: Set(item.price_cents),
..Default::default() ..Default::default()
} }

View File

@@ -2,7 +2,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::models::_entities::{categories, product_variants, products}; use crate::models::_entities::{categories, product_images, product_variants, products};
use crate::shared::money::format_price; use crate::shared::money::format_price;
use crate::shared::pricing::PricedProduct; use crate::shared::pricing::PricedProduct;
@@ -34,6 +34,8 @@ pub fn product_card(
"currency": product.currency, "currency": product.currency,
"sku": representative.sku, "sku": representative.sku,
"stock": representative.stock, "stock": representative.stock,
"tracked": representative.tracked(),
"in_stock": representative.in_stock(),
"variant_count": variant_count, "variant_count": variant_count,
"has_options": variant_count > 1, "has_options": variant_count > 1,
"published": product.published, "published": product.published,
@@ -49,7 +51,8 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
"label": variant.label, "label": variant.label,
"sku": variant.sku, "sku": variant.sku,
"stock": variant.stock, "stock": variant.stock,
"in_stock": variant.stock > 0, "tracked": variant.tracked(),
"in_stock": variant.in_stock(),
"price": format_price(priced.price_cents), "price": format_price(priced.price_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"regular_price": format_price(priced.regular_cents), "regular_price": format_price(priced.regular_cents),
@@ -58,9 +61,9 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
} }
/// Shape used to pre-fill the admin product form (exposes `category_id` rather /// Shape used to pre-fill the admin product form (exposes `category_id` rather
/// than a resolved name, and the current primary image). Variants are supplied /// than a resolved name, and the current images in display order — first is the
/// separately by the controller. /// main one). Variants are supplied separately by the controller.
pub fn product_form(product: &products::Model, image: Option<String>) -> Value { pub fn product_form(product: &products::Model, images: &[product_images::Model]) -> Value {
json!({ json!({
"id": product.id, "id": product.id,
"name": product.name, "name": product.name,
@@ -69,7 +72,10 @@ pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
"currency": product.currency, "currency": product.currency,
"published": product.published, "published": product.published,
"category_id": product.category_id, "category_id": product.category_id,
"image": image, "images": images
.iter()
.map(|im| json!({ "id": im.id, "image_id": im.image_id }))
.collect::<Vec<_>>(),
}) })
} }