From 71210b6bdb9a0eba4fcd9d00587e6b7420c2d00b Mon Sep 17 00:00:00 2001 From: byrsapty Date: Sun, 6 Sep 2026 03:28:05 +0300 Subject: [PATCH] =?UTF-8?q?Add=20independent=20on/off=20toggles=20for=20th?= =?UTF-8?q?e=20"=E2=89=88"=20estimate=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bober_bbq/admin/routes.py | 2 + bober_bbq/migrate.py | 2 + bober_bbq/models.py | 19 ++++- bober_bbq/templates/admin/product_form.html | 14 ++++ tests/test_pricing.py | 84 ++++++++++++++++++++ webapp/src/components/CartItemRow.tsx | 13 ++- webapp/src/components/ProductCard.tsx | 12 ++- webapp/src/components/ProductDetailModal.tsx | 12 ++- webapp/src/types.ts | 2 + 9 files changed, 156 insertions(+), 4 deletions(-) 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; }