Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
681c88f85d | ||
|
|
6828854f24 | ||
|
|
3a1ea7cdb4 |
@@ -212,6 +212,9 @@ sale-price = Sale price
|
|||||||
variants-options = Variants / options
|
variants-options = Variants / options
|
||||||
add-option = Add option
|
add-option = Add option
|
||||||
option-label = Option label
|
option-label = Option label
|
||||||
|
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
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ sale-price = Zľavnená cena
|
|||||||
variants-options = Varianty / možnosti
|
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é
|
||||||
|
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
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,9 +37,12 @@
|
|||||||
|
|
||||||
{# --- 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/sku, and optional public & #}
|
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
|
||||||
{# business sale prices). Rows are managed client-side; names are indexed #}
|
{# optional — leave it blank ("∞") to mark the option simply available (not #}
|
||||||
{# (variants[i][...]) and read back by the controller. #}
|
{# inventory-tracked). SKU and business price are optional too. Rows are #}
|
||||||
|
{# managed client-side; names are indexed (variants[i][…]) and read back by #}
|
||||||
|
{# the controller. #}
|
||||||
|
{% 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))">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -51,53 +54,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-for="(row, i) in rows" :key="i">
|
<template x-for="(row, i) in rows" :key="i">
|
||||||
<div class="grid gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 sm:grid-cols-12 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
<div class="flex items-end gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
||||||
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
|
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
|
||||||
|
|
||||||
<div class="space-y-1 sm:col-span-5">
|
{# items-end bottom-aligns every input regardless of how many lines each
|
||||||
<label class="{{ sublabel }}">{{ t(key="option-label", lang=lang | default(value='sk')) }}</label>
|
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
||||||
|
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
||||||
|
<div class="space-y-1 col-span-2 sm:col-span-4">
|
||||||
|
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 sm:col-span-3">
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<label class="{{ sublabel }}">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
<label class="{{ sublabel }} block truncate">{{ t(key="sku", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<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 }}">{{ 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" :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="flex items-end sm:col-span-2">
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<button type="button" @click="remove(i)"
|
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||||
class="ml-auto rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<div class="space-y-1 sm:col-span-4">
|
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<label class="{{ sublabel }}">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" class="{{ inp }}" placeholder="0.00">
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 sm:col-span-4">
|
|
||||||
<label class="{{ sublabel }}">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<input :name="`variants[${i}][sale]`" x-model="row.sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 sm:col-span-4">
|
|
||||||
<label class="{{ sublabel }}">{{ t(key="business-price", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button type="button" @click="remove(i)"
|
||||||
|
class="mb-1 shrink-0 rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function variantEditor(initial) {
|
function variantEditor(initial) {
|
||||||
const blank = () => ({ id: '', label: '', sku: '', stock: 0, price: '', sale: '', business_sale: '' });
|
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' });
|
||||||
return {
|
return {
|
||||||
rows: (initial || []).map(r => ({
|
rows: (initial || []).map(r => ({
|
||||||
id: r.id || '',
|
id: r.id || '',
|
||||||
label: r.label || '',
|
label: r.label || '',
|
||||||
sku: r.sku || '',
|
sku: r.sku || '',
|
||||||
stock: (r.stock === null || r.stock === undefined) ? 0 : r.stock,
|
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
||||||
price: r.price || '',
|
price: r.price || '',
|
||||||
sale: r.sale || '',
|
|
||||||
business_sale: r.business_sale || '',
|
business_sale: r.business_sale || '',
|
||||||
})),
|
})),
|
||||||
init() { if (this.rows.length === 0) this.add(); },
|
init() { if (this.rows.length === 0) this.add(); },
|
||||||
|
|||||||
@@ -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() }}">
|
||||||
|
|||||||
@@ -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 }}';
|
||||||
|
|||||||
@@ -60,6 +60,19 @@
|
|||||||
|
|
||||||
<template x-if="current">
|
<template x-if="current">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<!-- option picker (only when there's a real choice); first option is
|
||||||
|
selected by default and switching it updates the price + buy form -->
|
||||||
|
<template x-if="variants.length > 1">
|
||||||
|
<div class="max-w-sm space-y-1.5">
|
||||||
|
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
||||||
|
<template x-for="(v, i) in variants" :key="v.id">
|
||||||
|
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex items-baseline gap-3">
|
||||||
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
||||||
<span x-text="current.price"></span> {{ product.currency }}
|
<span x-text="current.price"></span> {{ product.currency }}
|
||||||
@@ -73,18 +86,6 @@
|
|||||||
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- variant picker (only when there's a real choice) -->
|
|
||||||
<template x-if="variants.length > 1">
|
|
||||||
<div class="max-w-sm space-y-1.5">
|
|
||||||
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
|
||||||
<template x-for="(v, i) in variants" :key="v.id">
|
|
||||||
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="current.in_stock">
|
<template x-if="current.in_stock">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||||
@@ -97,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">
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
36
migration/src/m20260622_000003_variant_stock_nullable.rs
Normal file
36
migration/src/m20260622_000003_variant_stock_nullable.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,15 +102,15 @@ 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,
|
||||||
sale_cents: Option<i64>,
|
|
||||||
business_sale_cents: Option<i64>,
|
business_sale_cents: Option<i64>,
|
||||||
position: i32,
|
position: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An optional price field on a variant row (sale / business sale): blank means
|
/// The optional business-sale price field on a variant row: blank means "no
|
||||||
/// "no quick-sale", a value must parse and be below the regular price.
|
/// business quick-sale", a value must parse and be below the regular price.
|
||||||
fn parse_optional_sale(
|
fn parse_optional_sale(
|
||||||
form: &MultipartForm,
|
form: &MultipartForm,
|
||||||
i: usize,
|
i: usize,
|
||||||
@@ -157,12 +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]")) {
|
||||||
|
None => None,
|
||||||
|
Some(raw) => Some(
|
||||||
|
raw.parse::<i32>()
|
||||||
|
.ok()
|
||||||
.filter(|n| *n >= 0)
|
.filter(|n| *n >= 0)
|
||||||
.unwrap_or(0);
|
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
|
||||||
let sale_cents = parse_optional_sale(form, i, "sale", price_cents)?;
|
),
|
||||||
|
};
|
||||||
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]"))
|
||||||
@@ -174,7 +179,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
sku,
|
sku,
|
||||||
stock,
|
stock,
|
||||||
price_cents,
|
price_cents,
|
||||||
sale_cents,
|
|
||||||
business_sale_cents,
|
business_sale_cents,
|
||||||
position: out.len() as i32,
|
position: out.len() as i32,
|
||||||
});
|
});
|
||||||
@@ -193,7 +197,8 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
|
|||||||
active.sku = Set(input.sku.clone());
|
active.sku = Set(input.sku.clone());
|
||||||
active.stock = Set(input.stock);
|
active.stock = Set(input.stock);
|
||||||
active.price_cents = Set(input.price_cents);
|
active.price_cents = Set(input.price_cents);
|
||||||
active.sale_price_cents = Set(input.sale_cents);
|
// The per-variant public sale price was removed from the UI; keep it cleared.
|
||||||
|
active.sale_price_cents = Set(None);
|
||||||
active.business_sale_price_cents = Set(input.business_sale_cents);
|
active.business_sale_price_cents = Set(input.business_sale_cents);
|
||||||
active.position = Set(input.position);
|
active.position = Set(input.position);
|
||||||
}
|
}
|
||||||
@@ -252,7 +257,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
|
|||||||
"sku": variant.sku,
|
"sku": variant.sku,
|
||||||
"stock": variant.stock,
|
"stock": variant.stock,
|
||||||
"price": format_price(variant.price_cents),
|
"price": format_price(variant.price_cents),
|
||||||
"sale": variant.sale_price_cents.map(format_price),
|
|
||||||
"business_sale": variant.business_sale_price_cents.map(format_price),
|
"business_sale": variant.business_sale_price_cents.map(format_price),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -318,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,
|
||||||
));
|
));
|
||||||
@@ -352,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 {
|
||||||
@@ -361,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -65,12 +65,16 @@ 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
|
||||||
|
// always available and never decremented.
|
||||||
|
if let Some(on_hand) = variant.stock {
|
||||||
|
if on_hand < *qty {
|
||||||
return Err(Error::BadRequest(format!(
|
return Err(Error::BadRequest(format!(
|
||||||
"not enough stock for {}",
|
"not enough stock for {}",
|
||||||
product.name
|
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
|
||||||
// business account, their negotiated/lowest price (same resolver the
|
// business account, their negotiated/lowest price (same resolver the
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
if let Some(on_hand) = variant.stock {
|
||||||
let mut active = variant.clone().into_active_model();
|
let mut active = variant.clone().into_active_model();
|
||||||
active.stock = Set(variant.stock - *qty);
|
active.stock = Set(Some(on_hand - *qty));
|
||||||
active.update(&txn).await?;
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user