now products have different options, like different parameters
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-22 15:44:02 +02:00
parent 29854a972b
commit 3f798432a0
52 changed files with 1281 additions and 628 deletions

View File

@@ -209,6 +209,11 @@ product = Product
name = Name
price = Price
sale-price = Sale price
variants-options = Variants / options
add-option = Add option
option-label = Option label
choose-option = Choose an option
from-price = from { $price }
admin-discounts = Discounts
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
business-discount-desc = A baseline discount for all business accounts (off the regular price). Profiles and negotiated prices apply on top (lowest price wins).

View File

@@ -209,6 +209,11 @@ product = Produkt
name = Názov
price = Cena
sale-price = Zľavnená cena
variants-options = Varianty / možnosti
add-option = Pridať možnosť
option-label = Označenie možnosti
choose-option = Vyberte možnosť
from-price = od { $price }
admin-discounts = Zľavy
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
business-discount-desc = Základná zľava pre všetky firemné účty (z bežnej ceny). Profily a dohodnuté ceny sa uplatnia navyše (platí najnižšia cena).

File diff suppressed because one or more lines are too long

View File

@@ -29,7 +29,7 @@
<ul class="space-y-2 pb-3 text-sm">
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }} × {{ item.quantity }}</span>
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
</li>
{% endfor %}

View File

@@ -18,38 +18,95 @@
{{ ui::csrf_field() }}
{% if product %}
{% set v_name = product.name %}{% set v_price = product.price %}{% set v_currency = product.currency %}{% set v_stock = product.stock %}{% set v_sku = product.sku | default(value="") %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %}
{% set v_name = product.name %}{% set v_currency = product.currency %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %}
{% else %}
{% set v_name = "" %}{% set v_price = "" %}{% set v_currency = "EUR" %}{% set v_stock = 0 %}{% set v_sku = "" %}{% set v_desc = "" %}{% set v_pub = false %}
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_pub = false %}
{% endif %}
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
<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>
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
</div>
<div class="grid gap-5 sm:grid-cols-2">
<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>
{{ ui::input(name="price", id="price", required=true, value=v_price, placeholder="0.00", attrs='inputmode="decimal"') }}
</div>
<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>
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
</div>
<div class="space-y-1.5 sm:max-w-[10rem]">
<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>
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
</div>
<div class="grid gap-5 sm:grid-cols-2">
<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>
{{ ui::input(name="stock", id="stock", type="number", value=v_stock, attrs='min="0"') }}
</div>
<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>
{{ ui::input(name="sku", id="sku", value=v_sku) }}
{# --- Variants / options editor ------------------------------------------- #}
{# 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 & #}
{# business sale prices). Rows are managed client-side; names are indexed #}
{# (variants[i][...]) and read back by the controller. #}
<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="flex items-center justify-between">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="variants-options", lang=lang | default(value='sk')) }}</span>
<button type="button" @click="add()"
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-option", lang=lang | default(value='sk')) }}
</button>
</div>
<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">
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
<div class="space-y-1 sm:col-span-5">
<label class="{{ sublabel }}">{{ t(key="option-label", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
</div>
<div class="space-y-1 sm:col-span-3">
<label class="{{ sublabel }}">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
</div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }}">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}">
</div>
<div class="flex items-end sm:col-span-2">
<button type="button" @click="remove(i)"
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>
</div>
<div class="space-y-1 sm:col-span-4">
<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="—">
</div>
</div>
</template>
</div>
<script>
function variantEditor(initial) {
const blank = () => ({ id: '', label: '', sku: '', stock: 0, price: '', sale: '', business_sale: '' });
return {
rows: (initial || []).map(r => ({
id: r.id || '',
label: r.label || '',
sku: r.sku || '',
stock: (r.stock === null || r.stock === undefined) ? 0 : r.stock,
price: r.price || '',
sale: r.sale || '',
business_sale: r.business_sale || '',
})),
init() { if (this.rows.length === 0) this.add(); },
add() { this.rows.push(blank()); },
remove(i) { this.rows.splice(i, 1); if (this.rows.length === 0) this.add(); },
};
}
</script>
<div class="space-y-1.5">
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
<div class="relative">

View File

@@ -83,7 +83,7 @@
<tr>
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="sale-price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="variants-options", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
@@ -106,15 +106,8 @@
</div>
</div>
</td>
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">
{% if product.on_sale %}
<span class="font-medium text-danger">{{ product.sale_price }} {{ product.currency }}</span>
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ product.percent_off }}%)</span>
{% else %}
<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>
{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">{{ product.variant_count }}</td>
<td class="px-4 py-3 tabular-nums">
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>
</td>
@@ -129,14 +122,6 @@
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/discount/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
{% if product.on_sale %}
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount/remove?audience={{ audience }}"
onsubmit="return confirm('{{ t(key="discount-remove-confirm", lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
</form>
{% endif %}
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">

View File

@@ -8,7 +8,7 @@
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}{% if product.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ product.variant_label }}</span>{% endif %}</h1>
{{ ui::badge(label=t(key="negotiated-price", lang=lang | default(value='sk')), variant="info") }}
</div>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.name }}</p>
@@ -20,7 +20,7 @@
<div class="mt-4 max-w-md">{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}</div>
{% endif %}
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.id }}"
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.variant_id }}"
x-data="{
price: '{{ negotiated }}',
regular: {{ product.regular_cents }},
@@ -71,7 +71,7 @@
<div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(variant="secondary", label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
{% if has_negotiated %}
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/customers/` ~ customer.id ~ `/prices/` ~ product.id ~ `/remove" onclick="return confirm('` ~ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/customers/` ~ customer.id ~ `/prices/` ~ product.variant_id ~ `/remove" onclick="return confirm('` ~ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
{% endif %}
</div>
</form>
@@ -83,7 +83,7 @@
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="automated-price", lang=lang | default(value='sk')) }}</h2>
{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}
</div>
<form method="post" action="/admin/customers/{{ customer.id }}/resolutions/{{ product.id }}" class="mt-3 flex items-center gap-2">
<form method="post" action="/admin/customers/{{ customer.id }}/resolutions/{{ product.variant_id }}" class="mt-3 flex items-center gap-2">
{{ ui::csrf_field() }}
<select name="profile_id" class="rounded-radius border border-outline bg-surface-alt px-2 py-1.5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
{% for c in covering %}

View File

@@ -61,7 +61,10 @@
<tbody class="{{ ui::tbody_cls() }}">
{% for product in products %}
<tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</td>
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ product.name }}
{% if product.variant_label %}<span class="block text-xs font-normal text-on-surface/60 dark:text-on-surface-dark/60">{{ product.variant_label }}</span>{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">
{% if product.business_reduced %}
<span class="font-medium text-danger">{{ product.business_price }} {{ product.currency }}</span>
@@ -76,9 +79,9 @@
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
{{ ui::button(variant="outline-secondary", label=t(key="set-negotiated-price", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id ~ "/prices/" ~ product.product_id ~ "/edit", size="px-3 py-1.5 text-xs") }}
{{ ui::button(variant="outline-secondary", label=t(key="set-negotiated-price", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id ~ "/prices/" ~ product.variant_id ~ "/edit", size="px-3 py-1.5 text-xs") }}
{% if product.has_negotiated %}
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.product_id }}/remove"
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.variant_id }}/remove"
onsubmit="return confirm('{{ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}

View File

@@ -36,7 +36,7 @@
<tbody class="{{ ui::tbody_cls() }}">
{% for item in items %}
<tr>
<td class="px-4 py-3">{{ item.product_name }}</td>
<td class="px-4 py-3">{{ item.product_name }}{% if item.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ item.variant_label }}</span>{% endif %}</td>
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} {{ order.currency }}</td>
</tr>

View File

@@ -20,21 +20,24 @@
{% if product.on_sale %}
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
</span>
{% else %}
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
{% endif %}
</div>
</div>
</a>
<div class="flex flex-col gap-2 p-6 pt-0">
{% if product.stock > 0 %}
{% if product.has_options %}
{# 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") }}
{% elif product.stock > 0 %}
<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>
<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')) }}')">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="variant_id" value="{{ product.variant_id }}">
<input type="hidden" name="quantity" value="1">
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
</form>

View File

@@ -19,6 +19,7 @@
<tr>
<td class="px-4 py-3">
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
{% if item.variant_label %}<span class="block text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">
{% if item.on_sale %}
@@ -35,7 +36,7 @@
<form method="post" action="/cart/update"
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }}
<input type="hidden" name="product_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 }}"
@change="
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
@@ -52,7 +53,7 @@
<form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }}
<input type="hidden" name="product_id" value="{{ item.id }}">
<input type="hidden" name="variant_id" value="{{ item.id }}">
{{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
</form>
</td>

View File

@@ -8,6 +8,7 @@
<div class="flex items-start gap-3 px-4 py-3">
<div class="min-w-0 flex-1">
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ item.currency }}</p>
</div>
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ item.currency }}</span>

View File

@@ -29,7 +29,7 @@
<ul class="space-y-2 py-3 text-sm">
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }} × {{ item.quantity }}</span>
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
</li>
{% endfor %}

View File

@@ -49,39 +49,77 @@
</div>
<!-- details -->
<div class="space-y-6">
{% set fld = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" %}
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
<div class="space-y-6" x-data="productBuy(JSON.parse(document.getElementById('variant-data').textContent))">
{% if category %}
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
{% if product.on_sale %}
<div class="flex items-baseline gap-3">
<p class="text-2xl font-semibold text-danger">{{ product.price }} {{ product.currency }}</p>
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</p>
</div>
{% else %}
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
{% endif %}
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
{% endif %}
<template x-if="current">
<div class="space-y-6">
<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'">
<span x-text="current.price"></span> {{ product.currency }}
</p>
<template x-if="current.on_sale">
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ product.currency }}</p>
</template>
</div>
{% if product.stock > 0 %}
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
<input type="hidden" name="product_id" value="{{ product.id }}">
<div class="space-y-1.5">
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="quantity", id="quantity", type="number", value="1", width="w-24", attrs='min="1" max="' ~ product.stock ~ '"') }}
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
{% 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">
<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"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
<input type="hidden" name="variant_id" :value="current.id">
<div class="space-y-1.5">
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
<input type="number" id="quantity" name="quantity" value="1" min="1" :max="current.stock" class="{{ fld }} w-24">
</div>
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
</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>
</div>
</template>
<template x-if="!current.in_stock">
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
</template>
</div>
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", size="px-5 py-2 text-sm") }}
</form>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
{% else %}
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
{% endif %}
</template>
<template x-if="!current">
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
</template>
</div>
<script>
function productBuy(variants) {
return {
variants: variants || [],
// Default to the first in-stock variant, else the first.
sel: Math.max(0, (variants || []).findIndex(v => v.in_stock)),
get current() { return this.variants[this.sel] || null; },
};
}
</script>
</div>
{% endblock content %}