diff --git a/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py b/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py new file mode 100644 index 0000000..1030660 --- /dev/null +++ b/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py @@ -0,0 +1,45 @@ +"""replace_price_tier_with_objective_price_fields + +Revision ID: 7c91e4b2af38 +Revises: e4f5a6b7c8d9 +Create Date: 2026-03-04 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "7c91e4b2af38" +down_revision: Union[str, None] = "e4f5a6b7c8d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("products", sa.Column("price_amount", sa.Float(), nullable=True)) + op.add_column( + "products", sa.Column("price_currency", sa.String(length=3), nullable=True) + ) + op.drop_index(op.f("ix_products_price_tier"), table_name="products") + op.drop_column("products", "price_tier") + op.execute("DROP TYPE IF EXISTS pricetier") + + +def downgrade() -> None: + op.execute("CREATE TYPE pricetier AS ENUM ('BUDGET', 'MID', 'PREMIUM', 'LUXURY')") + op.add_column( + "products", + sa.Column( + "price_tier", + sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"), + nullable=True, + ), + ) + op.create_index( + op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False + ) + op.drop_column("products", "price_currency") + op.drop_column("products", "price_amount") diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index e01780e..36eef02 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,6 +1,6 @@ import json from datetime import date -from typing import Optional +from typing import Literal, Optional from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Query @@ -17,6 +17,7 @@ from innercontext.llm import ( get_creative_config, get_extraction_config, ) +from innercontext.services.fx import convert_to_pln from innercontext.models import ( Product, ProductBase, @@ -67,8 +68,11 @@ class ProductUpdate(SQLModel): absorption_speed: Optional[AbsorptionSpeed] = None leave_on: Optional[bool] = None - price_tier: Optional[PriceTier] = None + price_amount: Optional[float] = None + price_currency: Optional[str] = None size_ml: Optional[float] = None + full_weight_g: Optional[float] = None + empty_weight_g: Optional[float] = None pao_months: Optional[int] = None inci: Optional[list[str]] = None @@ -119,7 +123,8 @@ class ProductParseResponse(SQLModel): texture: Optional[TextureType] = None absorption_speed: Optional[AbsorptionSpeed] = None leave_on: Optional[bool] = None - price_tier: Optional[PriceTier] = None + price_amount: Optional[float] = None + price_currency: Optional[str] = None size_ml: Optional[float] = None full_weight_g: Optional[float] = None empty_weight_g: Optional[float] = None @@ -213,6 +218,188 @@ class _ShoppingSuggestionsOut(PydanticBase): reasoning: str +# --------------------------------------------------------------------------- +# Pricing helpers +# --------------------------------------------------------------------------- + + +_MIN_PRODUCTS_FOR_PRICE_TIER = 8 +_MIN_CATEGORY_SIZE_FOR_FALLBACK = 4 +_ESTIMATED_AMOUNT_PER_USE: dict[ProductCategory, float] = { + ProductCategory.CLEANSER: 1.5, + ProductCategory.TONER: 1.5, + ProductCategory.ESSENCE: 1.0, + ProductCategory.SERUM: 0.35, + ProductCategory.MOISTURIZER: 0.8, + ProductCategory.SPF: 1.2, + ProductCategory.MASK: 2.5, + ProductCategory.EXFOLIANT: 0.7, + ProductCategory.HAIR_TREATMENT: 1.0, + ProductCategory.SPOT_TREATMENT: 0.1, + ProductCategory.OIL: 0.35, +} + + +def _estimated_amount_per_use(category: ProductCategory) -> float | None: + return _ESTIMATED_AMOUNT_PER_USE.get(category) + + +def _net_weight_g(product: Product) -> float | None: + if product.full_weight_g is None or product.empty_weight_g is None: + return None + net = product.full_weight_g - product.empty_weight_g + if net <= 0: + return None + return net + + +def _price_per_use_pln(product: Product) -> float | None: + if product.price_amount is None or product.price_currency is None: + return None + + amount_per_use = _estimated_amount_per_use(product.category) + if amount_per_use is None or amount_per_use <= 0: + return None + + pack_amount = product.size_ml + if pack_amount is None or pack_amount <= 0: + pack_amount = _net_weight_g(product) + if pack_amount is None or pack_amount <= 0: + return None + + uses_per_pack = pack_amount / amount_per_use + if uses_per_pack <= 0: + return None + + price_pln = convert_to_pln(product.price_amount, product.price_currency.upper()) + if price_pln is None: + return None + return price_pln / uses_per_pack + + +def _percentile(sorted_values: list[float], fraction: float) -> float: + if not sorted_values: + raise ValueError("sorted_values cannot be empty") + if len(sorted_values) == 1: + return sorted_values[0] + + position = (len(sorted_values) - 1) * fraction + low = int(position) + high = min(low + 1, len(sorted_values) - 1) + weight = position - low + return sorted_values[low] * (1 - weight) + sorted_values[high] * weight + + +def _tier_from_thresholds( + value: float, + *, + p25: float, + p50: float, + p75: float, +) -> PriceTier: + if value <= p25: + return PriceTier.BUDGET + if value <= p50: + return PriceTier.MID + if value <= p75: + return PriceTier.PREMIUM + return PriceTier.LUXURY + + +def _thresholds(values: list[float]) -> tuple[float, float, float]: + sorted_vals = sorted(values) + return ( + _percentile(sorted_vals, 0.25), + _percentile(sorted_vals, 0.50), + _percentile(sorted_vals, 0.75), + ) + + +def _compute_pricing_outputs( + products: list[Product], +) -> dict[ + UUID, + tuple[ + PriceTier | None, + float | None, + Literal["category", "fallback", "insufficient_data"] | None, + ], +]: + price_per_use_by_id: dict[UUID, float] = {} + grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {} + + for product in products: + ppu = _price_per_use_pln(product) + if ppu is None: + continue + price_per_use_by_id[product.id] = ppu + grouped.setdefault(product.category, []).append((product.id, ppu)) + + outputs: dict[ + UUID, + tuple[ + PriceTier | None, + float | None, + Literal["category", "fallback", "insufficient_data"] | None, + ], + ] = { + p.id: ( + None, + price_per_use_by_id.get(p.id), + "insufficient_data" if p.id in price_per_use_by_id else None, + ) + for p in products + } + + fallback_rows: list[tuple[UUID, float]] = [] + for product in products: + ppu = price_per_use_by_id.get(product.id) + if ppu is None: + continue + if product.is_tool or product.is_medication or not product.leave_on: + continue + fallback_rows.append((product.id, ppu)) + + fallback_thresholds: tuple[float, float, float] | None = None + if len(fallback_rows) >= _MIN_PRODUCTS_FOR_PRICE_TIER: + fallback_thresholds = _thresholds([ppu for _, ppu in fallback_rows]) + + for category_rows in grouped.values(): + if len(category_rows) < _MIN_PRODUCTS_FOR_PRICE_TIER: + if ( + len(category_rows) >= _MIN_CATEGORY_SIZE_FOR_FALLBACK + and fallback_thresholds is not None + ): + p25, p50, p75 = fallback_thresholds + for product_id, ppu in category_rows: + tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75) + outputs[product_id] = (tier, ppu, "fallback") + continue + + p25, p50, p75 = _thresholds([ppu for _, ppu in category_rows]) + + for product_id, ppu in category_rows: + tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75) + outputs[product_id] = (tier, ppu, "category") + + return outputs + + +def _with_pricing( + view: ProductPublic, + pricing: tuple[ + PriceTier | None, + float | None, + Literal["category", "fallback", "insufficient_data"] | None, + ], +) -> ProductPublic: + price_tier, price_per_use_pln, price_tier_source = pricing + view.price_tier = price_tier + view.price_per_use_pln = price_per_use_pln + view.price_tier_source = price_tier_source + return view + + # --------------------------------------------------------------------------- # Product routes # --------------------------------------------------------------------------- @@ -267,8 +454,12 @@ def list_products( inv_by_product.setdefault(inv.product_id, []).append(inv) results = [] + pricing_pool = list(session.exec(select(Product)).all()) if products else [] + pricing_outputs = _compute_pricing_outputs(pricing_pool) + for p in products: r = ProductWithInventory.model_validate(p, from_attributes=True) + _with_pricing(r, pricing_outputs.get(p.id, (None, None, None))) r.inventory = inv_by_product.get(p.id, []) results.append(r) return results @@ -276,9 +467,13 @@ def list_products( @router.post("", response_model=ProductPublic, status_code=201) def create_product(data: ProductCreate, session: Session = Depends(get_session)): + payload = data.model_dump() + if payload.get("price_currency"): + payload["price_currency"] = str(payload["price_currency"]).upper() + product = Product( id=uuid4(), - **data.model_dump(), + **payload, ) session.add(product) session.commit() @@ -324,8 +519,6 @@ texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fl absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow" -price_tier: "budget" | "mid" | "premium" | "luxury" - recommended_for (array, pick applicable): "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone" @@ -355,7 +548,8 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): "texture": string, "absorption_speed": string, "leave_on": boolean, - "price_tier": string, + "price_amount": number, + "price_currency": string, "size_ml": number, "full_weight_g": number, "empty_weight_g": number, @@ -440,10 +634,14 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: @router.get("/{product_id}", response_model=ProductWithInventory) def get_product(product_id: UUID, session: Session = Depends(get_session)): product = get_or_404(session, Product, product_id) + pricing_pool = list(session.exec(select(Product)).all()) + pricing_outputs = _compute_pricing_outputs(pricing_pool) + inventory = session.exec( select(ProductInventory).where(ProductInventory.product_id == product_id) ).all() result = ProductWithInventory.model_validate(product, from_attributes=True) + _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) result.inventory = list(inventory) return result @@ -453,12 +651,19 @@ def update_product( product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) ): product = get_or_404(session, Product, product_id) - for key, value in data.model_dump(exclude_unset=True).items(): + patch_data = data.model_dump(exclude_unset=True) + if patch_data.get("price_currency"): + patch_data["price_currency"] = str(patch_data["price_currency"]).upper() + + for key, value in patch_data.items(): setattr(product, key, value) session.add(product) session.commit() session.refresh(product) - return product + pricing_pool = list(session.exec(select(Product)).all()) + pricing_outputs = _compute_pricing_outputs(pricing_pool) + result = ProductPublic.model_validate(product, from_attributes=True) + return _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) @router.delete("/{product_id}", status_code=204) diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index f2881e6..afd74c7 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -94,7 +94,8 @@ class ProductBase(SQLModel): absorption_speed: AbsorptionSpeed | None = None leave_on: bool - price_tier: PriceTier | None = None + price_amount: float | None = Field(default=None, gt=0) + price_currency: str | None = Field(default=None, min_length=3, max_length=3) size_ml: float | None = Field(default=None, gt=0) full_weight_g: float | None = Field(default=None, gt=0) empty_weight_g: float | None = Field(default=None, gt=0) @@ -145,9 +146,6 @@ class Product(ProductBase, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) - # Override: add index for table context - price_tier: PriceTier | None = Field(default=None, index=True) - # Override 9 JSON fields with sa_column (only in table model) inci: list[str] = Field( default_factory=list, sa_column=Column(JSON, nullable=False) @@ -217,9 +215,17 @@ class Product(ProductBase, table=True): if self.is_medication and not self.usage_notes: raise ValueError("Medication products must have usage_notes") + if self.price_currency is not None: + self.price_currency = self.price_currency.upper() + return self - def to_llm_context(self) -> dict: + def to_llm_context( + self, + *, + computed_price_tier: PriceTier | None = None, + price_per_use_pln: float | None = None, + ) -> dict: ctx: dict = { "id": str(self.id), "name": self.name, @@ -238,8 +244,14 @@ class Product(ProductBase, table=True): ctx["texture"] = _ev(self.texture) if self.absorption_speed is not None: ctx["absorption_speed"] = _ev(self.absorption_speed) - if self.price_tier is not None: - ctx["price_tier"] = _ev(self.price_tier) + if self.price_amount is not None: + ctx["price_amount"] = self.price_amount + if self.price_currency is not None: + ctx["price_currency"] = self.price_currency + if computed_price_tier is not None: + ctx["price_tier"] = _ev(computed_price_tier) + if price_per_use_pln is not None: + ctx["price_per_use_pln"] = round(price_per_use_pln, 4) if self.size_ml is not None: ctx["size_ml"] = self.size_ml if self.pao_months is not None: @@ -370,6 +382,9 @@ class ProductPublic(ProductBase): id: UUID created_at: datetime updated_at: datetime + price_tier: PriceTier | None = None + price_per_use_pln: float | None = None + price_tier_source: str | None = None class ProductWithInventory(ProductPublic): diff --git a/backend/innercontext/services/__init__.py b/backend/innercontext/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/innercontext/services/fx.py b/backend/innercontext/services/fx.py new file mode 100644 index 0000000..f9f13c0 --- /dev/null +++ b/backend/innercontext/services/fx.py @@ -0,0 +1,77 @@ +import json +from datetime import datetime, timedelta, timezone +from threading import Lock +from urllib.error import URLError +from urllib.request import Request, urlopen + +NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A" +_CACHE_TTL = timedelta(hours=24) + +_cache_lock = Lock() +_cached_rates: dict[str, float] | None = None +_cached_at: datetime | None = None + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _cache_is_fresh() -> bool: + if _cached_rates is None or _cached_at is None: + return False + return _now_utc() - _cached_at < _CACHE_TTL + + +def _fetch_rates_from_nbp() -> dict[str, float]: + req = Request(NBP_TABLE_A_URL, headers={"Accept": "application/json"}) + with urlopen(req, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + + if not isinstance(payload, list) or not payload: + raise ValueError("Unexpected NBP payload") + + table = payload[0] + rates = table.get("rates") if isinstance(table, dict) else None + if not isinstance(rates, list): + raise ValueError("NBP payload does not include rates") + + parsed: dict[str, float] = {"PLN": 1.0} + for row in rates: + if not isinstance(row, dict): + continue + code = row.get("code") + mid = row.get("mid") + if not isinstance(code, str) or not isinstance(mid, (int, float)): + continue + parsed[code.upper()] = float(mid) + + return parsed + + +def get_pln_rates() -> dict[str, float]: + global _cached_rates, _cached_at + + with _cache_lock: + if _cache_is_fresh() and _cached_rates is not None: + return dict(_cached_rates) + + try: + fresh = _fetch_rates_from_nbp() + except (URLError, TimeoutError, ValueError): + if _cached_rates is not None: + return dict(_cached_rates) + return {"PLN": 1.0} + + _cached_rates = fresh + _cached_at = _now_utc() + return dict(fresh) + + +def convert_to_pln(amount: float, currency: str) -> float | None: + if amount <= 0: + return None + rates = get_pln_rates() + rate = rates.get(currency.upper()) + if rate is None: + return None + return amount * rate diff --git a/backend/tests/test_products_pricing.py b/backend/tests/test_products_pricing.py new file mode 100644 index 0000000..1abdeca --- /dev/null +++ b/backend/tests/test_products_pricing.py @@ -0,0 +1,177 @@ +import uuid + +from innercontext.api import products as products_api +from innercontext.models import Product +from innercontext.models.enums import DayTime, ProductCategory + + +def _product( + *, category: ProductCategory, price_amount: float, size_ml: float +) -> Product: + return Product( + id=uuid.uuid4(), + name=f"{category}-{price_amount}", + brand="Brand", + category=category, + recommended_time=DayTime.BOTH, + leave_on=True, + price_amount=price_amount, + price_currency="PLN", + size_ml=size_ml, + ) + + +def test_compute_pricing_outputs_groups_by_category(monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + serums = [ + _product(category=ProductCategory.SERUM, price_amount=float(i), size_ml=35.0) + for i in range(10, 90, 10) + ] + cleansers = [ + _product( + category=ProductCategory.CLEANSER, price_amount=float(i), size_ml=200.0 + ) + for i in range(40, 120, 10) + ] + outputs = products_api._compute_pricing_outputs(serums + cleansers) + + serum_tiers = [outputs[p.id][0] for p in serums] + cleanser_tiers = [outputs[p.id][0] for p in cleansers] + + assert serum_tiers[0] == "budget" + assert serum_tiers[-1] == "luxury" + assert cleanser_tiers[0] == "budget" + assert cleanser_tiers[-1] == "luxury" + + +def test_price_tier_is_null_when_not_enough_products(client, monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + } + for i in range(7): + response = client.post( + "/products", + json={ + **base, + "name": f"Serum {i}", + "category": "serum", + "price_amount": 30 + i, + }, + ) + assert response.status_code == 201 + + products = client.get("/products").json() + assert len(products) == 7 + assert all(p["price_tier"] is None for p in products) + assert all(p["price_per_use_pln"] is not None for p in products) + + +def test_price_tier_is_computed_on_list(client, monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + "category": "serum", + } + for i in range(8): + response = client.post( + "/products", + json={**base, "name": f"Serum {i}", "price_amount": 20 + i * 10}, + ) + assert response.status_code == 201 + + products = client.get("/products").json() + assert len(products) == 8 + assert any(p["price_tier"] == "budget" for p in products) + assert any(p["price_tier"] == "luxury" for p in products) + + +def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + serum_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + "category": "serum", + } + for i in range(8): + response = client.post( + "/products", + json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5}, + ) + assert response.status_code == 201 + + toner_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 100.0, + "price_currency": "PLN", + "category": "toner", + } + for i in range(5): + response = client.post( + "/products", + json={**toner_base, "name": f"Toner {i}", "price_amount": 10 + i * 5}, + ) + assert response.status_code == 201 + + products = client.get("/products?category=toner").json() + assert len(products) == 5 + assert all(p["price_tier"] is not None for p in products) + assert all(p["price_tier_source"] == "fallback" for p in products) + + +def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool( + client, monkeypatch +): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + serum_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + "category": "serum", + } + for i in range(8): + response = client.post( + "/products", + json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5}, + ) + assert response.status_code == 201 + + oil_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 30.0, + "price_currency": "PLN", + "category": "oil", + } + for i in range(3): + response = client.post( + "/products", + json={**oil_base, "name": f"Oil {i}", "price_amount": 30 + i * 10}, + ) + assert response.status_code == 201 + + oils = client.get("/products?category=oil").json() + assert len(oils) == 3 + assert all(p["price_tier"] is None for p in oils) + assert all(p["price_tier_source"] == "insufficient_data" for p in oils) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ddaac4a..43f5db4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -108,7 +108,8 @@ export interface ProductParseResponse { texture?: string; absorption_speed?: string; leave_on?: boolean; - price_tier?: string; + price_amount?: number; + price_currency?: string; size_ml?: number; full_weight_g?: number; empty_weight_g?: number; diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte index e2e730e..004dfe7 100644 --- a/frontend/src/lib/components/ProductForm.svelte +++ b/frontend/src/lib/components/ProductForm.svelte @@ -20,7 +20,6 @@ ]; const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid']; const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow']; - const priceTiers = ['budget', 'mid', 'premium', 'luxury']; const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone']; const skinConcerns = [ 'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration', @@ -71,13 +70,6 @@ very_slow: m["productForm_absorptionVerySlow"]() }); - const priceTierLabels = $derived>({ - budget: m["productForm_priceBudget"](), - mid: m["productForm_priceMid"](), - premium: m["productForm_pricePremium"](), - luxury: m["productForm_priceLuxury"]() - }); - const skinTypeLabels = $derived>({ dry: m["productForm_skinTypeDry"](), oily: m["productForm_skinTypeOily"](), @@ -210,7 +202,8 @@ if (r.recommended_time) recommendedTime = r.recommended_time; if (r.texture) texture = r.texture; if (r.absorption_speed) absorptionSpeed = r.absorption_speed; - if (r.price_tier) priceTier = r.price_tier; + if (r.price_amount != null) priceAmount = String(r.price_amount); + if (r.price_currency) priceCurrency = r.price_currency; if (r.leave_on != null) leaveOn = String(r.leave_on); if (r.size_ml != null) sizeMl = String(r.size_ml); if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g); @@ -260,7 +253,8 @@ let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true'))); let texture = $state(untrack(() => product?.texture ?? '')); let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? '')); - let priceTier = $state(untrack(() => product?.price_tier ?? '')); + let priceAmount = $state(untrack(() => (product?.price_amount != null ? String(product.price_amount) : ''))); + let priceCurrency = $state(untrack(() => product?.price_currency ?? 'PLN')); let fragranceFree = $state( untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : '')) ); @@ -776,16 +770,13 @@
- - - + + +
+ +
+ +
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4f69b05..a414675 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -42,6 +42,7 @@ export type MedicationKind = export type OverallSkinState = "excellent" | "good" | "fair" | "poor"; export type PartOfDay = "am" | "pm"; export type PriceTier = "budget" | "mid" | "premium" | "luxury"; +export type PriceTierSource = "category" | "fallback" | "insufficient_data"; export type ProductCategory = | "cleanser" | "toner" @@ -147,7 +148,11 @@ export interface Product { texture?: TextureType; absorption_speed?: AbsorptionSpeed; leave_on: boolean; + price_amount?: number; + price_currency?: string; price_tier?: PriceTier; + price_per_use_pln?: number; + price_tier_source?: PriceTierSource; size_ml?: number; full_weight_g?: number; empty_weight_g?: number; diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte index 276e9b2..36e98ba 100644 --- a/frontend/src/routes/products/+page.svelte +++ b/frontend/src/routes/products/+page.svelte @@ -55,6 +55,31 @@ })()); const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0)); + + function formatPricePerUse(value?: number): string { + if (value == null) return '-'; + return `${value.toFixed(2)} PLN/use`; + } + + function formatTier(value?: string): string { + if (!value) return 'n/a'; + return value; + } + + function getPricePerUse(product: Product): number | undefined { + return (product as Product & { price_per_use_pln?: number }).price_per_use_pln; + } + + function getTierSource(product: Product): string | undefined { + return (product as Product & { price_tier_source?: string }).price_tier_source; + } + + function sourceLabel(product: Product): string { + const source = getTierSource(product); + if (source === 'fallback') return 'fallback'; + if (source === 'insufficient_data') return 'insufficient'; + return 'category'; + } {m.products_title()} — innercontext @@ -92,26 +117,27 @@ {m["products_colBrand"]()} {m["products_colTargets"]()} {m["products_colTime"]()} + Pricing {#if totalCount === 0} - + {m["products_noProducts"]()} {:else} {#each groupedProducts as [category, products] (category)} - + {category.replace(/_/g, ' ')} {#each products as product (product.id)} - + {product.name} @@ -127,6 +153,13 @@
{product.recommended_time} + +
+ {formatPricePerUse(getPricePerUse(product))} + {formatTier(product.price_tier)} + {sourceLabel(product)} +
+
{/each} {/each} @@ -145,14 +178,19 @@ {category.replace(/_/g, ' ')}
{#each products as product (product.id)} - +

{product.name}

{product.brand}

+
+ {formatPricePerUse(getPricePerUse(product))} + {formatTier(product.price_tier)} + {sourceLabel(product)} +
{product.recommended_time}
diff --git a/frontend/src/routes/products/[id]/+page.server.ts b/frontend/src/routes/products/[id]/+page.server.ts index 03a41ad..29a95ca 100644 --- a/frontend/src/routes/products/[id]/+page.server.ts +++ b/frontend/src/routes/products/[id]/+page.server.ts @@ -119,17 +119,19 @@ export const actions: Actions = { }; // Optional strings - for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) { + for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) { const v = parseOptionalString(form.get(field) as string | null); body[field] = v ?? null; } // Optional enum selects (null if empty = clearing the value) - for (const field of ['texture', 'absorption_speed', 'price_tier']) { + for (const field of ['texture', 'absorption_speed']) { const v = form.get(field) as string | null; body[field] = v || null; } + body.price_amount = parseOptionalFloat(form.get('price_amount') as string | null) ?? null; + // Optional numbers body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null; body.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null; diff --git a/frontend/src/routes/products/[id]/+page.svelte b/frontend/src/routes/products/[id]/+page.svelte index 82e3aef..ef73fe9 100644 --- a/frontend/src/routes/products/[id]/+page.svelte +++ b/frontend/src/routes/products/[id]/+page.svelte @@ -16,6 +16,43 @@ let showInventoryForm = $state(false); let editingInventoryId = $state(null); + + function formatAmount(amount?: number, currency?: string): string { + if (amount == null || !currency) return '-'; + try { + return new Intl.NumberFormat('pl-PL', { + style: 'currency', + currency: currency.toUpperCase(), + maximumFractionDigits: 2 + }).format(amount); + } catch { + return `${amount.toFixed(2)} ${currency.toUpperCase()}`; + } + } + + function formatPricePerUse(value?: number): string { + if (value == null) return '-'; + return `${value.toFixed(2)} PLN/use`; + } + + function getPriceAmount(): number | undefined { + return (product as { price_amount?: number }).price_amount; + } + + function getPriceCurrency(): string | undefined { + return (product as { price_currency?: string }).price_currency; + } + + function getPricePerUse(): number | undefined { + return (product as { price_per_use_pln?: number }).price_per_use_pln; + } + + function getTierSource(): string { + const source = (product as { price_tier_source?: string }).price_tier_source; + if (source === 'fallback') return 'fallback'; + if (source === 'insufficient_data') return 'insufficient_data'; + return 'category'; + } {product.name} — innercontext @@ -33,6 +70,26 @@
{m.common_saved()}
{/if} + + +
+
+

Price

+

{formatAmount(getPriceAmount(), getPriceCurrency())}

+
+
+

Price per use

+

{formatPricePerUse(getPricePerUse())}

+
+
+

Price tier

+

{product.price_tier ?? 'n/a'}

+

source: {getTierSource()}

+
+
+
+
+
diff --git a/frontend/src/routes/products/new/+page.server.ts b/frontend/src/routes/products/new/+page.server.ts index 6162c84..32bf0f4 100644 --- a/frontend/src/routes/products/new/+page.server.ts +++ b/frontend/src/routes/products/new/+page.server.ts @@ -107,17 +107,20 @@ export const actions: Actions = { }; // Optional strings - for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) { + for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) { const v = parseOptionalString(form.get(field) as string | null); if (v !== undefined) payload[field] = v; } // Optional enum selects - for (const field of ['texture', 'absorption_speed', 'price_tier']) { + for (const field of ['texture', 'absorption_speed']) { const v = form.get(field) as string | null; if (v) payload[field] = v; } + const price_amount = parseOptionalFloat(form.get('price_amount') as string | null); + if (price_amount !== undefined) payload.price_amount = price_amount; + // Optional numbers const size_ml = parseOptionalFloat(form.get('size_ml') as string | null); if (size_ml !== undefined) payload.size_ml = size_ml;