Add independent on/off toggles for the "≈" estimate badge

Previously the badge always showed next to the price for any per_100g
product and never next to the weight at all. Adds two per-product
checkboxes (show_estimate_badge_price, default on; show_estimate_badge_weight,
default off) so the admin can turn either one on or off independently —
covers a shashlik-style product where the weight itself is also just an
estimate, not only the price.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-09-06 03:28:05 +03:00
parent 6a0579f24d
commit 71210b6bdb
9 changed files with 156 additions and 4 deletions

View file

@ -539,6 +539,8 @@ def _apply_product_form(product: Product):
product.estimated_price_override = (
price_override if product.pricing_mode == "per_100g" and price_override and price_override > 0 else None
)
product.show_estimate_badge_price = bool(request.form.get("show_estimate_badge_price"))
product.show_estimate_badge_weight = bool(request.form.get("show_estimate_badge_weight"))
product.sort_order = request.form.get("sort_order", type=int) or 0
product.is_active = bool(request.form.get("is_active"))
product.allergens = ",".join(a for a in request.form.getlist("allergens") if a in ALLERGENS)

View file

@ -61,3 +61,5 @@ def migrate() -> None:
_add_column_if_missing("products", "pricing_mode", "pricing_mode VARCHAR(16) DEFAULT 'fixed'")
_add_column_if_missing("products", "reference_weight_g", "reference_weight_g INTEGER")
_add_column_if_missing("products", "estimated_price_override", "estimated_price_override INTEGER")
_add_column_if_missing("products", "show_estimate_badge_price", "show_estimate_badge_price BOOLEAN DEFAULT TRUE")
_add_column_if_missing("products", "show_estimate_badge_weight", "show_estimate_badge_weight BOOLEAN DEFAULT FALSE")

View file

@ -147,6 +147,13 @@ class Product(db.Model):
# / 100. Weight stays a separate, always-editable field regardless (it's
# also shown to the customer on its own). Meaningless outside per_100g.
estimated_price_override = db.Column(db.Integer)
# Independent on/off switches for the customer-facing "≈" badge — only
# meaningful for per_100g products. Price defaults on (matches the
# behavior before these existed); weight defaults off (a new, opt-in
# capability — most per_100g products only ever showed the badge next
# to the price).
show_estimate_badge_price = db.Column(db.Boolean, default=True, nullable=False)
show_estimate_badge_weight = db.Column(db.Boolean, default=False, nullable=False)
image_url = db.Column(db.String(256), default="")
is_active = db.Column(db.Boolean, default=True, nullable=False)
sort_order = db.Column(db.Integer, default=0, nullable=False)
@ -172,7 +179,15 @@ class Product(db.Model):
@property
def is_estimated_price(self) -> bool:
return self.pricing_mode == "per_100g"
"""Whether the "" badge should render next to the price — the
admin can turn this off per-product even for a per_100g item."""
return self.pricing_mode == "per_100g" and self.show_estimate_badge_price
@property
def is_estimated_weight(self) -> bool:
"""Same as is_estimated_price, but for the badge next to the
weight label instead independent toggle, off by default."""
return self.pricing_mode == "per_100g" and self.show_estimate_badge_weight
@property
def allergen_codes(self) -> list[str]:
@ -196,6 +211,7 @@ class Product(db.Model):
"weight": (self.weight_en if use_en and self.weight_en else self.weight) or "",
"price": self.effective_unit_price,
"is_estimated_price": self.is_estimated_price,
"is_estimated_weight": self.is_estimated_weight,
"image_url": self.image_url,
"allergens": self.allergen_details(locale),
}
@ -413,6 +429,7 @@ class CartItem(db.Model):
"image_url": self.product.image_url,
"price": self.product.effective_unit_price,
"is_estimated_price": self.product.is_estimated_price,
"is_estimated_weight": self.product.is_estimated_weight,
"quantity": self.quantity,
"line_total": self.line_total,
}

View file

@ -61,6 +61,19 @@
</div>
<p class="muted field-hint" id="estimate-hint" style="margin-top:-10px;"></p>
<div id="estimate-badges-row" class="row">
<label style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="show_estimate_badge_price" class="admin-checkbox"
{{ 'checked' if not product or product.show_estimate_badge_price }}>
Показувати «≈» біля ціни
</label>
<label style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="show_estimate_badge_weight" class="admin-checkbox"
{{ 'checked' if product and product.show_estimate_badge_weight }}>
Показувати «≈» біля ваги
</label>
</div>
<label>Фото</label>
{% if product and product.image_url %}
<img class="thumb" src="{{ product.image_url }}" style="width:80px;height:80px;margin-bottom:8px;">
@ -101,6 +114,7 @@
var isPer100g = document.getElementById('pricing-mode').value === 'per_100g';
document.getElementById('reference-weight-row').style.display = isPer100g ? 'flex' : 'none';
document.getElementById('estimate-hint').style.display = isPer100g ? 'block' : 'none';
document.getElementById('estimate-badges-row').style.display = isPer100g ? 'flex' : 'none';
document.getElementById('price-label').textContent = isPer100g ? 'Ціна за 100 г, грн' : 'Ціна, грн';
recalcEstimate();
}

View file

@ -54,6 +54,37 @@ def test_effective_unit_price_per_100g_computes_estimate(app):
product = db.session.get(Product, product_id)
assert product.effective_unit_price == 450
assert product.is_estimated_price is True
# Price badge defaults on, weight badge defaults off.
assert product.is_estimated_weight is False
def test_estimate_badges_are_independently_toggleable(app):
with app.app_context():
product_id = _make_per_100g_product(app)
product = db.session.get(Product, product_id)
product.show_estimate_badge_price = False
product.show_estimate_badge_weight = True
db.session.commit()
product = db.session.get(Product, product_id)
assert product.is_estimated_price is False
assert product.is_estimated_weight is True
def test_estimate_badges_are_always_off_for_fixed_pricing(app):
with app.app_context():
cat_id = _make_category(app)
product = Product(
category_id=cat_id,
name="Звичайний товар",
price=100,
show_estimate_badge_price=True,
show_estimate_badge_weight=True,
)
# The badge toggles are only meaningful for per_100g — a fixed-price
# product never shows "≈", regardless of what's stored in them.
assert product.is_estimated_price is False
assert product.is_estimated_weight is False
def test_effective_unit_price_per_100g_without_reference_weight_falls_back_to_raw_price(app):
@ -75,6 +106,7 @@ def test_effective_unit_price_per_100g_manual_override_takes_precedence(app):
pricing_mode="per_100g",
reference_weight_g=300, # would compute to 450
estimated_price_override=480,
show_estimate_badge_price=True, # column default only applies on flush/commit
)
assert product.effective_unit_price == 480
assert product.is_estimated_price is True
@ -108,6 +140,58 @@ def test_apply_product_form_saves_manual_estimated_price(app):
assert product.effective_unit_price == 480
def test_apply_product_form_saves_badge_toggles(app):
client = app.test_client()
_admin_login(client, app)
cat_id = _make_category(app)
# Simulates a real browser submit where the price checkbox is checked
# (rendered pre-checked by default) but the weight one isn't.
resp = client.post(
"/admin/products/new",
data={
"category_id": cat_id,
"name": "Шашлик з бейджем біля ваги",
"description": "",
"weight": "",
"price": "150",
"pricing_mode": "per_100g",
"reference_weight_g": "300",
"show_estimate_badge_price": "on",
"show_estimate_badge_weight": "on",
"sort_order": "0",
},
follow_redirects=True,
)
assert resp.status_code == 200
with app.app_context():
product = Product.query.filter_by(name="Шашлик з бейджем біля ваги").first()
assert product is not None
assert product.is_estimated_price is True
assert product.is_estimated_weight is True
product_id = product.id
# Unchecking both (fields simply absent from the POST) turns both off.
client.post(
f"/admin/products/{product_id}/edit",
data={
"category_id": cat_id,
"name": "Шашлик з бейджем біля ваги",
"description": "",
"weight": "",
"price": "150",
"pricing_mode": "per_100g",
"reference_weight_g": "300",
"sort_order": "0",
},
follow_redirects=True,
)
with app.app_context():
product = db.session.get(Product, product_id)
assert product.is_estimated_price is False
assert product.is_estimated_weight is False
def test_apply_product_form_clears_override_when_switching_to_fixed(app):
client = app.test_client()
_admin_login(client, app)

View file

@ -19,7 +19,18 @@ export function CartItemRow({ item, onChangeQty, onRemove }: Props) {
<div className="info">
<div className="name">{item.name}</div>
<div className="sub">
{item.weight ? `${item.weight} · ` : ""}
{item.weight && (
<>
{item.weight}
{item.is_estimated_weight && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>
{" "}
</span>
)}
{" · "}
</>
)}
{item.price} {t("common.currency")}
{item.is_estimated_price && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>

View file

@ -72,7 +72,17 @@ export function ProductCard({ product, onAdd, onOpenDetail }: Props) {
<div className="info">
<div className="name">{product.name}</div>
{product.description && <div className="desc">{product.description}</div>}
{product.weight && <div className="meta">{product.weight}</div>}
{product.weight && (
<div className="meta">
{product.weight}
{product.is_estimated_weight && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>
{" "}
</span>
)}
</div>
)}
{product.allergens.length > 0 && (
<div className="allergen-row">
{product.allergens.map((a) => (

View file

@ -72,7 +72,17 @@ export function ProductDetailModal({ product, onClose, onAdd }: Props) {
<div className="product-modal-body">
<div className="product-modal-name">{product.name}</div>
{product.weight && <div className="product-modal-meta">{product.weight}</div>}
{product.weight && (
<div className="product-modal-meta">
{product.weight}
{product.is_estimated_weight && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>
{" "}
</span>
)}
</div>
)}
{product.description && <p className="product-modal-desc">{product.description}</p>}
{product.allergens.length > 0 && (

View file

@ -19,6 +19,7 @@ export interface Product {
weight: string;
price: number;
is_estimated_price: boolean;
is_estimated_weight: boolean;
image_url: string;
allergens: Allergen[];
}
@ -30,6 +31,7 @@ export interface CartLine {
image_url: string;
price: number;
is_estimated_price: boolean;
is_estimated_weight: boolean;
quantity: number;
line_total: number;
}