diff --git a/bober_bbq/admin/routes.py b/bober_bbq/admin/routes.py
index 224dfd4..99dcd88 100644
--- a/bober_bbq/admin/routes.py
+++ b/bober_bbq/admin/routes.py
@@ -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)
diff --git a/bober_bbq/migrate.py b/bober_bbq/migrate.py
index 69b8113..5fe9fac 100644
--- a/bober_bbq/migrate.py
+++ b/bober_bbq/migrate.py
@@ -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")
diff --git a/bober_bbq/models.py b/bober_bbq/models.py
index d7ace05..8e6b088 100644
--- a/bober_bbq/models.py
+++ b/bober_bbq/models.py
@@ -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,
}
diff --git a/bober_bbq/templates/admin/product_form.html b/bober_bbq/templates/admin/product_form.html
index 1f29902..b938008 100644
--- a/bober_bbq/templates/admin/product_form.html
+++ b/bober_bbq/templates/admin/product_form.html
@@ -61,6 +61,19 @@
+
+
+
+
+
{% if product and product.image_url %}
@@ -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();
}
diff --git a/tests/test_pricing.py b/tests/test_pricing.py
index e511732..9133233 100644
--- a/tests/test_pricing.py
+++ b/tests/test_pricing.py
@@ -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)
diff --git a/webapp/src/components/CartItemRow.tsx b/webapp/src/components/CartItemRow.tsx
index 49344ab..c286747 100644
--- a/webapp/src/components/CartItemRow.tsx
+++ b/webapp/src/components/CartItemRow.tsx
@@ -19,7 +19,18 @@ export function CartItemRow({ item, onChangeQty, onRemove }: Props) {
{item.name}
- {item.weight ? `${item.weight} · ` : ""}
+ {item.weight && (
+ <>
+ {item.weight}
+ {item.is_estimated_weight && (
+
+ {" "}
+ ≈
+
+ )}
+ {" · "}
+ >
+ )}
{item.price} {t("common.currency")}
{item.is_estimated_price && (
diff --git a/webapp/src/components/ProductCard.tsx b/webapp/src/components/ProductCard.tsx
index 7808c1f..b155ad6 100644
--- a/webapp/src/components/ProductCard.tsx
+++ b/webapp/src/components/ProductCard.tsx
@@ -72,7 +72,17 @@ export function ProductCard({ product, onAdd, onOpenDetail }: Props) {
{product.name}
{product.description &&
{product.description}
}
- {product.weight &&
{product.weight}
}
+ {product.weight && (
+
+ {product.weight}
+ {product.is_estimated_weight && (
+
+ {" "}
+ ≈
+
+ )}
+
+ )}
{product.allergens.length > 0 && (
{product.allergens.map((a) => (
diff --git a/webapp/src/components/ProductDetailModal.tsx b/webapp/src/components/ProductDetailModal.tsx
index 62e5512..bcba173 100644
--- a/webapp/src/components/ProductDetailModal.tsx
+++ b/webapp/src/components/ProductDetailModal.tsx
@@ -72,7 +72,17 @@ export function ProductDetailModal({ product, onClose, onAdd }: Props) {
{product.name}
- {product.weight &&
{product.weight}
}
+ {product.weight && (
+
+ {product.weight}
+ {product.is_estimated_weight && (
+
+ {" "}
+ ≈
+
+ )}
+
+ )}
{product.description &&
{product.description}
}
{product.allergens.length > 0 && (
diff --git a/webapp/src/types.ts b/webapp/src/types.ts
index 501a951..b2bca27 100644
--- a/webapp/src/types.ts
+++ b/webapp/src/types.ts
@@ -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;
}