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:
parent
6a0579f24d
commit
71210b6bdb
9 changed files with 156 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue