feat(products): compute price tiers from objective price/use

This commit is contained in:
Piotr Oleszczyk 2026-03-04 14:47:18 +01:00
parent c5ea38880c
commit 83ba4cc5c0
13 changed files with 664 additions and 48 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -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<Record<string, string>>({
budget: m["productForm_priceBudget"](),
mid: m["productForm_priceMid"](),
premium: m["productForm_pricePremium"](),
luxury: m["productForm_priceLuxury"]()
});
const skinTypeLabels = $derived<Record<string, string>>({
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 @@
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} />
<Select type="single" value={priceTier} onValueChange={(v) => (priceTier = v)}>
<SelectTrigger>{priceTier ? priceTierLabels[priceTier] : m["productForm_selectTier"]()}</SelectTrigger>
<SelectContent>
{#each priceTiers as p}
<SelectItem value={p}>{priceTierLabels[p]}</SelectItem>
{/each}
</SelectContent>
</Select>
<Label for="price_amount">Price</Label>
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder="e.g. 79.99" bind:value={priceAmount} />
</div>
<div class="space-y-2">
<Label for="price_currency">Currency</Label>
<Input id="price_currency" name="price_currency" maxlength={3} placeholder="PLN" bind:value={priceCurrency} />
</div>
<div class="space-y-2">

View file

@ -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;

View file

@ -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';
}
</script>
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
@ -92,26 +117,27 @@
<TableHead>{m["products_colBrand"]()}</TableHead>
<TableHead>{m["products_colTargets"]()}</TableHead>
<TableHead>{m["products_colTime"]()}</TableHead>
<TableHead>Pricing</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if totalCount === 0}
<TableRow>
<TableCell colspan={4} class="text-center text-muted-foreground py-8">
<TableCell colspan={5} class="text-center text-muted-foreground py-8">
{m["products_noProducts"]()}
</TableCell>
</TableRow>
{:else}
{#each groupedProducts as [category, products] (category)}
<TableRow class="bg-muted/30 hover:bg-muted/30">
<TableCell colspan={4} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{category.replace(/_/g, ' ')}
</TableCell>
</TableRow>
{#each products as product (product.id)}
<TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell>
<a href="/products/{product.id}" class="font-medium hover:underline">
<a href={resolve(`/products/${product.id}`)} class="font-medium hover:underline">
{product.name}
</a>
</TableCell>
@ -127,6 +153,13 @@
</div>
</TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
<TableCell>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
</div>
</TableCell>
</TableRow>
{/each}
{/each}
@ -145,14 +178,19 @@
{category.replace(/_/g, ' ')}
</div>
{#each products as product (product.id)}
<a
href="/products/{product.id}"
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<a
href={resolve(`/products/${product.id}`)}
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
<div class="mt-1 flex items-center gap-2 text-xs">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
</div>
</div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
</div>

View file

@ -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;

View file

@ -16,6 +16,43 @@
let showInventoryForm = $state(false);
let editingInventoryId = $state<string | null>(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';
}
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
@ -33,6 +70,26 @@
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
{/if}
<Card>
<CardContent class="pt-4">
<div class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Price</p>
<p class="font-medium">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
</div>
<div>
<p class="text-muted-foreground">Price per use</p>
<p class="font-medium">{formatPricePerUse(getPricePerUse())}</p>
</div>
<div>
<p class="text-muted-foreground">Price tier</p>
<p class="font-medium uppercase">{product.price_tier ?? 'n/a'}</p>
<p class="text-xs text-muted-foreground">source: {getTierSource()}</p>
</div>
</div>
</CardContent>
</Card>
<!-- Edit form -->
<form method="POST" action="?/update" use:enhance class="space-y-6">
<ProductForm {product} />

View file

@ -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;