Compare commits

..

5 commits

Author SHA1 Message Date
c869f88db2 chore(backend): enable psycopg binary dependency 2026-03-04 21:46:38 +01:00
693c6a9626 feat(frontend): unify editorial UI and DRY form architecture 2026-03-04 21:43:37 +01:00
d4fbc1faf5 feat(frontend): streamline AI workflows and localize remaining UI copy
Move product and skin AI helpers into modal flows, simplify product edit/inventory navigation, and improve responsive actions so core forms are faster to use. Localize remaining frontend labels/placeholders and strip deprecated Rollup output options to remove deploy-time build warnings.
2026-03-04 18:13:49 +01:00
83ba4cc5c0 feat(products): compute price tiers from objective price/use 2026-03-04 14:47:18 +01:00
c5ea38880c refactor(products): remove obsolete interaction fields across stack 2026-03-04 12:42:12 +01:00
56 changed files with 4021 additions and 1797 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

@ -0,0 +1,28 @@
"""drop_product_interaction_columns
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-03-04 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_column("products", "incompatible_with")
op.drop_column("products", "synergizes_with")
def downgrade() -> None:
op.add_column("products", sa.Column("synergizes_with", sa.JSON(), nullable=True))
op.add_column("products", sa.Column("incompatible_with", sa.JSON(), nullable=True))

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,
@ -38,7 +39,6 @@ from innercontext.models.product import (
ActiveIngredient,
ProductContext,
ProductEffectProfile,
ProductInteraction,
)
router = APIRouter()
@ -68,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
@ -91,8 +94,6 @@ class ProductUpdate(SQLModel):
ph_min: Optional[float] = None
ph_max: Optional[float] = None
incompatible_with: Optional[list[ProductInteraction]] = None
synergizes_with: Optional[list[str]] = None
context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None
@ -122,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
@ -140,8 +142,6 @@ class ProductParseResponse(SQLModel):
product_effect_profile: Optional[ProductEffectProfile] = None
ph_min: Optional[float] = None
ph_max: Optional[float] = None
incompatible_with: Optional[list[ProductInteraction]] = None
synergizes_with: Optional[list[str]] = None
context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None
@ -218,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
# ---------------------------------------------------------------------------
@ -272,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
@ -281,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()
@ -329,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"
@ -347,8 +535,6 @@ actives[].functions (array, pick applicable):
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
actives[].irritation_potential: 1 (low) | 2 (medium) | 3 (high)
incompatible_with[].scope: "same_step" | "same_day" | "same_period"
OUTPUT SCHEMA (all fields optional omit what you cannot determine):
{
"name": string,
@ -362,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,
@ -402,10 +589,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
},
"ph_min": number,
"ph_max": number,
"incompatible_with": [
{"target": string, "scope": string, "reason": string}
],
"synergizes_with": [string, ...],
"context_rules": {
"safe_after_shaving": boolean,
"safe_after_acids": boolean,
@ -451,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
@ -464,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)
@ -721,7 +915,6 @@ def _build_safety_rules_tool_handler(products: list[Product]):
return {
"id": pid,
"name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {},
@ -755,7 +948,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules",
description=(
"Return safety and compatibility rules for selected product UUIDs, "
"including incompatible_with, contraindications, context_rules and safety flags."
"including contraindications, context_rules and safety flags."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,

View file

@ -378,8 +378,6 @@ def _build_products_context(
notable = {k: v for k, v in profile.items() if v and v > 0}
if notable:
entry += f" effects={notable}"
if ctx.get("incompatible_with"):
entry += f" incompatible_with={ctx['incompatible_with']}"
if ctx.get("contraindications"):
entry += f" contraindications={ctx['contraindications']}"
if ctx.get("context_rules"):
@ -501,7 +499,6 @@ def _build_safety_rules_tool_handler(
return {
"id": pid,
"name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {},
@ -630,7 +627,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules",
description=(
"Return safety and compatibility rules for selected product UUIDs: "
"incompatible_with, contraindications, context_rules and safety flags."
"contraindications, context_rules and safety flags."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
@ -688,8 +685,7 @@ WYMAGANIA ODPOWIEDZI:
ZASADY PLANOWANIA:
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules,
min_interval_hours, max_frequency_per_week, usage_notes.
- Respektuj: context_rules, min_interval_hours, max_frequency_per_week, usage_notes.
- Zarządzanie inwentarzem:
- Preferuj produkty już otwarte (miękka preferencja).
- Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie),

View file

@ -7,7 +7,6 @@ from .enums import (
EvidenceLevel,
GroomingAction,
IngredientFunction,
InteractionScope,
MedicationKind,
OverallSkinState,
PartOfDay,
@ -29,7 +28,6 @@ from .product import (
ProductBase,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory,
ProductPublic,
ProductWithInventory,
@ -53,7 +51,6 @@ __all__ = [
"EvidenceLevel",
"GroomingAction",
"IngredientFunction",
"InteractionScope",
"MedicationKind",
"OverallSkinState",
"PartOfDay",
@ -77,7 +74,6 @@ __all__ = [
"ProductBase",
"ProductContext",
"ProductEffectProfile",
"ProductInteraction",
"ProductInventory",
"ProductPublic",
"ProductWithInventory",

View file

@ -131,12 +131,6 @@ class EvidenceLevel(str, Enum):
HIGH = "high"
class InteractionScope(str, Enum):
SAME_STEP = "same_step"
SAME_DAY = "same_day"
SAME_PERIOD = "same_period"
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------

View file

@ -12,7 +12,6 @@ from .enums import (
AbsorptionSpeed,
DayTime,
IngredientFunction,
InteractionScope,
PriceTier,
ProductCategory,
SkinConcern,
@ -57,12 +56,6 @@ class ActiveIngredient(SQLModel):
irritation_potential: StrengthLevel | None = None
class ProductInteraction(SQLModel):
target: str
scope: InteractionScope
reason: str | None = None
class ProductContext(SQLModel):
safe_after_shaving: bool | None = None
safe_after_acids: bool | None = None
@ -101,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)
@ -128,8 +122,6 @@ class ProductBase(SQLModel):
ph_min: float | None = Field(default=None, ge=0, le=14)
ph_max: float | None = Field(default=None, ge=0, le=14)
incompatible_with: list[ProductInteraction] | None = None
synergizes_with: list[str] | None = None
context_rules: ProductContext | None = None
min_interval_hours: int | None = Field(default=None, ge=0)
@ -154,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)
@ -181,12 +170,6 @@ class Product(ProductBase, table=True):
sa_column=Column(JSON, nullable=False),
)
incompatible_with: list[ProductInteraction] | None = Field(
default=None, sa_column=Column(JSON, nullable=True)
)
synergizes_with: list[str] | None = Field(
default=None, sa_column=Column(JSON, nullable=True)
)
context_rules: ProductContext | None = Field(
default=None, sa_column=Column(JSON, nullable=True)
)
@ -232,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,
@ -253,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:
@ -302,26 +299,6 @@ class Product(ProductBase, table=True):
if nonzero:
ctx["effect_profile"] = nonzero
if self.incompatible_with:
parts = []
for inc in self.incompatible_with:
if isinstance(inc, dict):
scope = inc.get("scope", "")
target = inc.get("target", "")
reason = inc.get("reason")
else:
scope = _ev(inc.scope)
target = inc.target
reason = inc.reason
msg = f"avoid {target} ({scope})"
if reason:
msg += f": {reason}"
parts.append(msg)
ctx["incompatible_with"] = parts
if self.synergizes_with:
ctx["synergizes_with"] = self.synergizes_with
if self.context_rules is not None:
cr = self.context_rules
if isinstance(cr, dict):
@ -405,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

@ -8,7 +8,7 @@ dependencies = [
"alembic>=1.14",
"fastapi>=0.132.0",
"google-genai>=1.65.0",
"psycopg>=3.3.3",
"psycopg[binary]>=3.3.3",
"python-dotenv>=1.2.1",
"python-multipart>=0.0.22",
"sqlmodel>=0.0.37",

View file

@ -7,14 +7,12 @@ from innercontext.models import Product
from innercontext.models.enums import (
DayTime,
IngredientFunction,
InteractionScope,
ProductCategory,
)
from innercontext.models.product import (
ActiveIngredient,
ProductContext,
ProductEffectProfile,
ProductInteraction,
)
@ -156,29 +154,6 @@ def test_effect_profile_nonzero_included():
assert "retinoid_strength" not in ctx["effect_profile"]
# ---------------------------------------------------------------------------
# Incompatible_with
# ---------------------------------------------------------------------------
def test_incompatible_with_pydantic_objects():
inc = ProductInteraction(
target="AHA", scope=InteractionScope.SAME_DAY, reason="increases irritation"
)
p = _make(incompatible_with=[inc])
ctx = p.to_llm_context()
assert "incompatible_with" in ctx
assert ctx["incompatible_with"][0] == "avoid AHA (same_day): increases irritation"
def test_incompatible_with_raw_dicts():
raw = {"target": "Vitamin C", "scope": "same_step", "reason": None}
p = _make(incompatible_with=[raw])
ctx = p.to_llm_context()
assert "incompatible_with" in ctx
assert ctx["incompatible_with"][0] == "avoid Vitamin C (same_step)"
# ---------------------------------------------------------------------------
# Context rules
# ---------------------------------------------------------------------------

View file

@ -136,7 +136,6 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
usage_notes="Use AM and PM on clean skin.",
inci=["Water", "Niacinamide"],
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
@ -153,5 +152,4 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
safety_data = _build_safety_rules_tool_handler([product])(payload)
assert "incompatible_with" in safety_data["products"][0]
assert "context_rules" in safety_data["products"][0]

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

@ -184,7 +184,6 @@ def test_build_products_context(session: Session):
recommended_time="am",
pao_months=6,
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
incompatible_with=[{"target": "retinol", "scope": "same_routine"}],
context_rules={"safe_after_shaving": False},
min_interval_hours=12,
max_frequency_per_week=7,
@ -233,7 +232,6 @@ def test_build_products_context(session: Session):
assert "nearest_open_pao_deadline=" in ctx
assert "pao_months=6" in ctx
assert "effects={'hydration_immediate': 2}" in ctx
assert "incompatible_with=['avoid retinol (same_routine)']" in ctx
assert "context_rules={'safe_after_shaving': False}" in ctx
assert "min_interval_hours=12" in ctx
assert "max_frequency_per_week=7" in ctx
@ -390,7 +388,6 @@ def test_additional_tool_handlers_return_product_payloads(session: Session):
leave_on=True,
usage_notes="Apply morning and evening.",
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Retinol", "scope": "same_step"}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
@ -404,5 +401,4 @@ def test_additional_tool_handlers_return_product_payloads(session: Session):
assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening."
safety_out = _build_safety_rules_tool_handler([p])(ids_payload)
assert "incompatible_with" in safety_out["products"][0]
assert "context_rules" in safety_out["products"][0]

49
backend/uv.lock generated
View file

@ -556,7 +556,7 @@ dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "google-genai" },
{ name = "psycopg" },
{ name = "psycopg", extra = ["binary"] },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlmodel" },
@ -579,7 +579,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" },
{ name = "fastapi", specifier = ">=0.132.0" },
{ name = "google-genai", specifier = ">=1.65.0" },
{ name = "psycopg", specifier = ">=3.3.3" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "sqlmodel", specifier = ">=0.0.37" },
@ -739,6 +739,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.2"

View file

@ -0,0 +1,189 @@
# Frontend Design Cookbook
This cookbook defines the visual system for the frontend so every new change extends the existing style instead of inventing a new one.
## Design intent
- Core tone: light editorial, calm and information-first.
- Product feel: premium personal logbook, not generic SaaS dashboard.
- Contrast model: neutral paper and ink do most of the work; accents are restrained.
## Non-negotiables
- Keep layouts readable first. Aesthetic details support hierarchy, not the other way around.
- Use one visual language across the app shell, cards, forms, tables, and actions.
- Prefer subtle depth (borders, layered paper, soft shadows) over loud gradients.
- Keep motion purposeful and short; always preserve `prefers-reduced-motion` behavior.
## Typography
- Display/headings: `Cormorant Infant`.
- Body/UI text: `Manrope`.
- Use display typography for page titles and section heads only.
- Keep paragraph text in body font for legibility.
## Color system
Global neutrals are defined in `frontend/src/app.css` using CSS variables.
- `--background`, `--card`, `--foreground`, `--muted-foreground`, `--border`
- `--page-accent` drives route-level emphasis.
- `--page-accent-soft` is the low-contrast companion tint.
### Domain accents (muted and professional)
- Dashboard: `--accent-dashboard`
- Products: `--accent-products`
- Routines: `--accent-routines`
- Skin: `--accent-skin`
- Health labs: `--accent-health-labs`
- Health medications: `--accent-health-meds`
The app shell assigns a domain class per route and maps it to `--page-accent`.
## Where to use accent color
Use accent for:
- active navigation state
- important buttons and key badges
- focus ring tint and hover border tint
- section separators and small status markers
Do not use accent for:
- full-page backgrounds
- body text
- large surfaces that reduce readability
Guideline: accent should occupy roughly 10-15% of visual area per screen.
## Layout rules
- Use the app shell spacing rhythm from `app.css` (`.app-main`, editorial cards).
- Keep max content width constrained for readability.
- Prefer asymmetry in hero/summary areas, but keep forms and dense data grids regular.
### Shared page wrappers
Use these wrappers before introducing route-specific structure:
- `editorial-page`: standard constrained content width for route pages.
- `editorial-hero`: top summary strip for title, subtitle, and primary actions.
- `editorial-panel`: primary surface for forms, tables, and ledgers.
- `editorial-toolbar`: compact action row under hero copy.
- `editorial-backlink`: standard top-left back navigation style.
- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`: feedback banners.
## Component rules
- Reuse UI primitives under `frontend/src/lib/components/ui/*`.
- Keep primitive APIs stable when changing visual treatment.
- Style via tokens and shared classes, not one-off hardcoded colors.
- New variants must be documented in this file.
### Existing shared utility patterns
These classes are already in use and should be reused:
- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row`
- Group headers: `products-section-title`
- Table shell: `products-table-shell`
- Tabs shell: `products-tabs`, `editorial-tabs`
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
## Forms and data views
- Inputs should remain high-contrast and calm.
- Validation/error states should be explicit and never color-only.
- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata.
### DRY form primitives
- Use shared form components for repeated native select markup:
- `frontend/src/lib/components/forms/SimpleSelect.svelte`
- `frontend/src/lib/components/forms/GroupedSelect.svelte`
- Use shared checkbox helper for repeated label+hint toggles:
- `frontend/src/lib/components/forms/HintCheckbox.svelte`
- Use shared input field helper for repeated label+input rows:
- `frontend/src/lib/components/forms/LabeledInputField.svelte`
- Use shared section card helper for repeated titled form panels:
- `frontend/src/lib/components/forms/FormSectionCard.svelte`
- Use shared class tokens from:
- `frontend/src/lib/components/forms/form-classes.ts`
- Prefer passing option labels from route files via `m.*` to keep i18n explicit.
### Select policy (performance + maintainability)
- Default to native `<select>` for enum-style fields and simple pickers.
- Use `SimpleSelect` / `GroupedSelect` for consistency and to reduce duplication.
- Avoid `ui/select` primitives unless search, custom keyboard behavior, or richer popup UX is truly needed.
- For grouped option sets (e.g. products by category), use `<optgroup>` via `GroupedSelect`.
### Chunk hygiene
- Large forms should be split into focused section components.
- Lazy-load heavyweight modal and optional sections where practical.
- After structural UI refactors, run `pnpm build` and inspect output for large chunks.
- Track and prevent regressions in known hotspots (form-heavy pages and shared interaction primitives).
## Motion
- Entry animations: short upward reveal with slight stagger.
- Hover interactions: subtle translation or tint only.
- Never stack multiple strong animations in the same viewport.
## Accessibility baseline
- Keyboard navigation for all interactive elements.
- Visible focus states on controls and links.
- Respect reduced motion.
- Maintain readable contrast for text, borders, and controls.
## i18n rules
- Do not add hardcoded UI labels in route components when a message key already exists.
- Prefer existing `m.*` keys from paraglide for headings, labels, empty states, and helper text.
- If a new label is required, add a new translation key instead of shipping English literals.
- Keep fallback display values localized (`m.common_unknown()` over hardcoded `n/a`).
- Avoid language-specific toggle text in reusable components unless fully localized.
## Implementation checklist for new UI work
1. Pick the route domain and rely on `--page-accent`.
2. Use existing typography pair and spacing rhythm.
3. Build with primitives first; add route-level wrappers only if needed.
4. Validate mobile and desktop layout.
5. Run:
- `pnpm check`
- `pnpm lint`
- `pnpm build` (for significant component architecture changes)
## File touchpoints
- Core tokens and global look: `frontend/src/app.css`
- App shell and route domain mapping: `frontend/src/routes/+layout.svelte`
- Route examples using the pattern:
- `frontend/src/routes/+page.svelte`
- `frontend/src/routes/products/+page.svelte`
- `frontend/src/routes/routines/+page.svelte`
- `frontend/src/routes/health/lab-results/+page.svelte`
- `frontend/src/routes/skin/+page.svelte`
- Primitive visuals:
- `frontend/src/lib/components/ui/button/button.svelte`
- `frontend/src/lib/components/ui/card/card.svelte`
- `frontend/src/lib/components/ui/input/input.svelte`
- `frontend/src/lib/components/ui/badge/badge.svelte`
- Shared form DRY helpers:
- `frontend/src/lib/components/forms/SimpleSelect.svelte`
- `frontend/src/lib/components/forms/GroupedSelect.svelte`
- `frontend/src/lib/components/forms/HintCheckbox.svelte`
- `frontend/src/lib/components/forms/LabeledInputField.svelte`
- `frontend/src/lib/components/forms/FormSectionCard.svelte`
- `frontend/src/lib/components/forms/form-classes.ts`
- i18n message source:
- `frontend/src/lib/paraglide/messages/*`
When introducing new visual patterns, update this cookbook in the same change.

View file

@ -22,6 +22,12 @@
"common_unknown_value": "Unknown",
"common_optional_notes": "optional",
"common_steps": "steps",
"common_am": "AM",
"common_pm": "PM",
"common_toggleMenu": "Toggle menu",
"common_dragToReorder": "drag to reorder",
"common_editStep": "edit step",
"common_pricePerUse": "PLN/use",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Your recent health & skincare overview",
@ -61,8 +67,9 @@
"products_colBrand": "Brand",
"products_colTargets": "Targets",
"products_colTime": "Time",
"products_colPricePerUse": "PLN/use",
"products_newTitle": "New Product",
"products_backToList": "Products",
"products_backToList": "Products",
"products_createProduct": "Create product",
"products_saveChanges": "Save changes",
"products_deleteProduct": "Delete product",
@ -106,7 +113,7 @@
"routines_addNew": "+ New routine",
"routines_noRoutines": "No routines found.",
"routines_newTitle": "New Routine",
"routines_backToList": "Routines",
"routines_backToList": "Routines",
"routines_detailsTitle": "Routine details",
"routines_date": "Date *",
"routines_amOrPm": "AM or PM *",
@ -124,12 +131,15 @@
"routines_dosePlaceholder": "e.g. 2 pumps",
"routines_region": "Region",
"routines_regionPlaceholder": "e.g. face",
"routines_action": "Action",
"routines_selectAction": "Select action",
"routines_actionNotesPlaceholder": "Optional notes",
"routines_addStepBtn": "Add step",
"routines_unknownStep": "Unknown step",
"routines_noSteps": "No steps yet.",
"grooming_title": "Grooming Schedule",
"grooming_backToRoutines": "Routines",
"grooming_backToRoutines": "Routines",
"grooming_addEntry": "+ Add entry",
"grooming_entryAdded": "Entry added.",
"grooming_entryUpdated": "Entry updated.",
@ -152,7 +162,7 @@
"grooming_daySunday": "Sunday",
"suggest_title": "AI Routine Suggestion",
"suggest_backToRoutines": "Routines",
"suggest_backToRoutines": "Routines",
"suggest_singleTab": "Single routine",
"suggest_batchTab": "Batch / Vacation",
"suggest_singleParams": "Parameters",
@ -165,6 +175,8 @@
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
"suggest_minimizeProductsLabel": "Minimize products",
"suggest_minimizeProductsHint": "Limit the number of different products",
"suggest_generateBtn": "Generate suggestion",
"suggest_generating": "Generating…",
"suggest_proposalTitle": "Suggestion",
@ -258,6 +270,7 @@
"labResults_flagNone": "None",
"labResults_date": "Date *",
"labResults_loincCode": "LOINC code *",
"labResults_loincExample": "e.g. 718-7",
"labResults_testName": "Test name",
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
"labResults_lab": "Lab",
@ -336,7 +349,7 @@
"productForm_aiPrefill": "AI pre-fill",
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
"productForm_pasteText": "Paste product description, INCI ingredients here...",
"productForm_parseWithAI": "Fill fields (AI)",
"productForm_parseWithAI": "Fill",
"productForm_parsing": "Processing…",
"productForm_basicInfo": "Basic info",
"productForm_name": "Name *",
@ -346,6 +359,7 @@
"productForm_lineName": "Line / series",
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
"productForm_url": "URL",
"productForm_urlPlaceholder": "https://...",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
"productForm_barcode": "Barcode / EAN",
@ -370,25 +384,18 @@
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
"productForm_ingredients": "Ingredients",
"productForm_inciList": "INCI list (one ingredient per line)",
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
"productForm_activeIngredients": "Active ingredients",
"productForm_addActive": "+ Add active",
"productForm_noActives": "No actives added yet.",
"productForm_activeName": "Name",
"productForm_activeNamePlaceholder": "e.g. Niacinamide",
"productForm_activePercent": "%",
"productForm_activePercentPlaceholder": "e.g. 5",
"productForm_activeStrength": "Strength",
"productForm_activeIrritation": "Irritation",
"productForm_activeFunctions": "Functions",
"productForm_effectProfile": "Effect profile (05)",
"productForm_interactions": "Interactions",
"productForm_synergizesWith": "Synergizes with (one per line)",
"productForm_incompatibleWith": "Incompatible with",
"productForm_addIncompatibility": "+ Add incompatibility",
"productForm_noIncompatibilities": "No incompatibilities added.",
"productForm_incompTarget": "Target ingredient",
"productForm_incompScope": "Scope",
"productForm_incompReason": "Reason (optional)",
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
"productForm_incompScopeSelect": "Select…",
"productForm_contextRules": "Context rules",
"productForm_ctxAfterShaving": "Safe after shaving",
"productForm_ctxAfterAcids": "Safe after acids",
@ -397,6 +404,19 @@
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
"productForm_productDetails": "Product details",
"productForm_priceTier": "Price tier",
"productForm_price": "Price",
"productForm_currency": "Currency",
"productForm_priceAmountPlaceholder": "e.g. 79.99",
"productForm_priceCurrencyPlaceholder": "PLN",
"productForm_sizePlaceholder": "e.g. 50",
"productForm_fullWeightPlaceholder": "e.g. 120",
"productForm_emptyWeightPlaceholder": "e.g. 30",
"productForm_paoPlaceholder": "e.g. 12",
"productForm_phMinPlaceholder": "e.g. 3.5",
"productForm_phMaxPlaceholder": "e.g. 4.5",
"productForm_minIntervalPlaceholder": "e.g. 24",
"productForm_maxFrequencyPlaceholder": "e.g. 3",
"productForm_needleLengthPlaceholder": "e.g. 0.25",
"productForm_selectTier": "Select tier",
"productForm_sizeMl": "Size (ml)",
"productForm_fullWeightG": "Full weight (g)",
@ -495,10 +515,6 @@
"productForm_fnVitaminC": "vitamin C",
"productForm_fnAntiAging": "anti-aging",
"productForm_scopeSameStep": "same step",
"productForm_scopeSameDay": "same day",
"productForm_scopeSamePeriod": "same period",
"productForm_strengthLow": "1 Low",
"productForm_strengthMedium": "2 Medium",
"productForm_strengthHigh": "3 High",

View file

@ -22,6 +22,12 @@
"common_unknown_value": "Nieznane",
"common_optional_notes": "opcjonalnie",
"common_steps": "kroków",
"common_am": "AM",
"common_pm": "PM",
"common_toggleMenu": "Przełącz menu",
"common_dragToReorder": "przeciągnij, aby zmienić kolejność",
"common_editStep": "edytuj krok",
"common_pricePerUse": "PLN/use",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
@ -63,8 +69,9 @@
"products_colBrand": "Marka",
"products_colTargets": "Cele",
"products_colTime": "Pora",
"products_colPricePerUse": "PLN/use",
"products_newTitle": "Nowy produkt",
"products_backToList": "Produkty",
"products_backToList": "Produkty",
"products_createProduct": "Utwórz produkt",
"products_saveChanges": "Zapisz zmiany",
"products_deleteProduct": "Usuń produkt",
@ -110,7 +117,7 @@
"routines_addNew": "+ Nowa rutyna",
"routines_noRoutines": "Nie znaleziono rutyn.",
"routines_newTitle": "Nowa rutyna",
"routines_backToList": "Rutyny",
"routines_backToList": "Rutyny",
"routines_detailsTitle": "Szczegóły rutyny",
"routines_date": "Data *",
"routines_amOrPm": "AM lub PM *",
@ -128,12 +135,15 @@
"routines_dosePlaceholder": "np. 2 pompki",
"routines_region": "Okolica",
"routines_regionPlaceholder": "np. twarz",
"routines_action": "Czynność",
"routines_selectAction": "Wybierz czynność",
"routines_actionNotesPlaceholder": "Opcjonalne notatki",
"routines_addStepBtn": "Dodaj krok",
"routines_unknownStep": "Nieznany krok",
"routines_noSteps": "Brak kroków.",
"grooming_title": "Harmonogram pielęgnacji",
"grooming_backToRoutines": "Rutyny",
"grooming_backToRoutines": "Rutyny",
"grooming_addEntry": "+ Dodaj wpis",
"grooming_entryAdded": "Wpis dodany.",
"grooming_entryUpdated": "Wpis zaktualizowany.",
@ -156,7 +166,7 @@
"grooming_daySunday": "Niedziela",
"suggest_title": "Propozycja rutyny AI",
"suggest_backToRoutines": "Rutyny",
"suggest_backToRoutines": "Rutyny",
"suggest_singleTab": "Jedna rutyna",
"suggest_batchTab": "Batch / Urlop",
"suggest_singleParams": "Parametry",
@ -169,6 +179,8 @@
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)",
"suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.",
"suggest_minimizeProductsLabel": "Minimalizuj produkty",
"suggest_minimizeProductsHint": "Ogranicz liczbę różnych produktów",
"suggest_generateBtn": "Generuj propozycję",
"suggest_generating": "Generuję…",
"suggest_proposalTitle": "Propozycja",
@ -270,6 +282,7 @@
"labResults_flagNone": "Brak",
"labResults_date": "Data *",
"labResults_loincCode": "Kod LOINC *",
"labResults_loincExample": "np. 718-7",
"labResults_testName": "Nazwa badania",
"labResults_testNamePlaceholder": "np. Hemoglobina",
"labResults_lab": "Laboratorium",
@ -350,7 +363,7 @@
"productForm_aiPrefill": "Uzupełnienie AI",
"productForm_aiPrefillText": "Wklej opis produktu ze strony, listę składników lub inny tekst. AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.",
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
"productForm_parseWithAI": "Uzupełnij pola (AI)",
"productForm_parseWithAI": "Uzupełnij",
"productForm_parsing": "Przetwarzam…",
"productForm_basicInfo": "Informacje podstawowe",
"productForm_name": "Nazwa *",
@ -360,6 +373,7 @@
"productForm_lineName": "Linia / seria",
"productForm_lineNamePlaceholder": "np. Hydro Boost",
"productForm_url": "URL",
"productForm_urlPlaceholder": "https://...",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "np. NTR-HB-50",
"productForm_barcode": "Kod kreskowy / EAN",
@ -384,25 +398,18 @@
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
"productForm_ingredients": "Składniki",
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
"productForm_activeIngredients": "Składniki aktywne",
"productForm_addActive": "+ Dodaj aktywny",
"productForm_noActives": "Brak składników aktywnych.",
"productForm_activeName": "Nazwa",
"productForm_activeNamePlaceholder": "np. Niacinamide",
"productForm_activePercent": "%",
"productForm_activePercentPlaceholder": "np. 5",
"productForm_activeStrength": "Siła",
"productForm_activeIrritation": "Podrażnienie",
"productForm_activeFunctions": "Funkcje",
"productForm_effectProfile": "Profil działania (05)",
"productForm_interactions": "Interakcje",
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
"productForm_incompatibleWith": "Niekompatybilny z",
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
"productForm_noIncompatibilities": "Brak niekompatybilności.",
"productForm_incompTarget": "Składnik docelowy",
"productForm_incompScope": "Zakres",
"productForm_incompReason": "Powód (opcjonalny)",
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
"productForm_incompScopeSelect": "Wybierz…",
"productForm_contextRules": "Reguły kontekstu",
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
@ -411,6 +418,19 @@
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
"productForm_productDetails": "Szczegóły produktu",
"productForm_priceTier": "Przedział cenowy",
"productForm_price": "Cena",
"productForm_currency": "Waluta",
"productForm_priceAmountPlaceholder": "np. 79.99",
"productForm_priceCurrencyPlaceholder": "PLN",
"productForm_sizePlaceholder": "np. 50",
"productForm_fullWeightPlaceholder": "np. 120",
"productForm_emptyWeightPlaceholder": "np. 30",
"productForm_paoPlaceholder": "np. 12",
"productForm_phMinPlaceholder": "np. 3.5",
"productForm_phMaxPlaceholder": "np. 4.5",
"productForm_minIntervalPlaceholder": "np. 24",
"productForm_maxFrequencyPlaceholder": "np. 3",
"productForm_needleLengthPlaceholder": "np. 0.25",
"productForm_selectTier": "Wybierz przedział",
"productForm_sizeMl": "Rozmiar (ml)",
"productForm_fullWeightG": "Waga pełna (g)",
@ -509,10 +529,6 @@
"productForm_fnVitaminC": "witamina C",
"productForm_fnAntiAging": "przeciwstarzeniowy",
"productForm_scopeSameStep": "ten sam krok",
"productForm_scopeSameDay": "ten sam dzień",
"productForm_scopeSamePeriod": "ten sam okres",
"productForm_strengthLow": "1 Niskie",
"productForm_strengthMedium": "2 Średnie",
"productForm_strengthHigh": "3 Wysokie",

View file

@ -5,26 +5,42 @@
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--background: hsl(42 35% 95%);
--foreground: hsl(220 24% 14%);
--card: hsl(44 32% 96%);
--card-foreground: hsl(220 24% 14%);
--popover: hsl(44 32% 96%);
--popover-foreground: hsl(220 24% 14%);
--primary: hsl(15 44% 34%);
--primary-foreground: hsl(42 40% 97%);
--secondary: hsl(38 24% 91%);
--secondary-foreground: hsl(220 20% 20%);
--muted: hsl(42 20% 90%);
--muted-foreground: hsl(219 12% 39%);
--accent: hsl(42 24% 90%);
--accent-foreground: hsl(220 24% 14%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 5.9% 10%);
--border: hsl(35 23% 76%);
--input: hsl(37 20% 80%);
--ring: hsl(15 40% 38%);
--radius: 0.5rem;
--editorial-paper: hsl(48 37% 96%);
--editorial-paper-strong: hsl(44 43% 92%);
--editorial-ink: hsl(220 23% 14%);
--editorial-muted: hsl(219 12% 39%);
--editorial-line: hsl(36 24% 74%);
--accent-dashboard: hsl(13 45% 39%);
--accent-products: hsl(95 28% 33%);
--accent-routines: hsl(186 27% 33%);
--accent-skin: hsl(16 51% 44%);
--accent-health-labs: hsl(212 41% 39%);
--accent-health-meds: hsl(140 31% 33%);
--page-accent: var(--accent-dashboard);
--page-accent-soft: hsl(24 42% 89%);
}
.dark {
@ -86,4 +102,723 @@
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Manrope', 'Segoe UI', sans-serif;
background-image:
radial-gradient(circle at 8% 4%, hsl(34 48% 90% / 0.62), transparent 36%),
linear-gradient(hsl(42 26% 95%), hsl(40 20% 93%));
}
.app-shell {
--page-accent: var(--accent-dashboard);
--page-accent-soft: hsl(18 40% 89%);
display: flex;
min-height: 100vh;
flex-direction: column;
}
.domain-dashboard {
--page-accent: var(--accent-dashboard);
--page-accent-soft: hsl(18 40% 89%);
}
.domain-products {
--page-accent: var(--accent-products);
--page-accent-soft: hsl(95 28% 89%);
}
.domain-routines {
--page-accent: var(--accent-routines);
--page-accent-soft: hsl(186 28% 88%);
}
.domain-skin {
--page-accent: var(--accent-skin);
--page-accent-soft: hsl(20 52% 88%);
}
.domain-health-labs {
--page-accent: var(--accent-health-labs);
--page-accent-soft: hsl(208 38% 88%);
}
.domain-health-meds {
--page-accent: var(--accent-health-meds);
--page-accent-soft: hsl(135 28% 88%);
}
.app-mobile-header {
border-bottom: 1px solid hsl(35 22% 76% / 0.7);
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(44 25% 94%));
}
.app-mobile-title,
.app-brand {
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.app-icon-button {
display: flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border: 1px solid hsl(34 21% 75%);
border-radius: 0.45rem;
color: var(--muted-foreground);
}
.app-icon-button:hover {
color: var(--foreground);
border-color: var(--page-accent);
background: var(--page-accent-soft);
}
.app-sidebar {
border-right: 1px solid hsl(36 20% 73% / 0.75);
background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%));
}
.app-sidebar a {
border: 1px solid transparent;
}
.app-sidebar a:hover {
border-color: hsl(35 23% 76% / 0.75);
}
.app-sidebar a.bg-accent {
border-color: color-mix(in srgb, var(--page-accent) 45%, white);
background: color-mix(in srgb, var(--page-accent) 13%, white);
color: var(--foreground);
}
.app-main {
flex: 1;
overflow: auto;
padding: 1rem;
}
.app-main > div {
margin: 0 auto;
width: min(1160px, 100%);
}
.app-main h2 {
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.9rem, 3.3vw, 2.7rem);
line-height: 1.02;
letter-spacing: 0.01em;
}
.app-main h3 {
font-family: 'Cormorant Infant', 'Times New Roman', serif;
}
.editorial-page {
width: min(1060px, 100%);
margin: 0 auto;
}
.editorial-backlink {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.875rem;
}
.editorial-backlink:hover {
color: var(--foreground);
}
.editorial-toolbar {
margin-top: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.editorial-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.editorial-alert {
border-radius: 0.7rem;
border: 1px solid hsl(34 25% 75% / 0.8);
background: hsl(42 36% 93%);
padding: 0.72rem 0.85rem;
font-size: 0.9rem;
}
.editorial-alert--error {
border-color: hsl(3 53% 71%);
background: hsl(4 72% 93%);
color: hsl(3 62% 34%);
}
.editorial-alert--success {
border-color: hsl(132 28% 72%);
background: hsl(127 36% 92%);
color: hsl(136 48% 26%);
}
.products-table-shell {
border: 1px solid hsl(35 24% 74% / 0.85);
border-radius: 0.9rem;
overflow: hidden;
}
.products-category-row {
background: color-mix(in srgb, var(--page-accent) 10%, white);
}
.products-mobile-card {
display: block;
border: 1px solid hsl(35 21% 76% / 0.85);
border-radius: 0.8rem;
padding: 0.95rem;
}
.products-section-title {
border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border));
padding-bottom: 0.3rem;
padding-top: 0.5rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.13em;
color: var(--muted-foreground);
text-transform: uppercase;
}
.products-sticky-actions {
border-color: color-mix(in srgb, var(--page-accent) 25%, var(--border));
}
.products-meta-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
color: var(--muted-foreground);
font-size: 0.9rem;
}
.products-tabs [data-slot='tabs-list'],
.editorial-tabs [data-slot='tabs-list'] {
border: 1px solid hsl(35 22% 75% / 0.75);
background: hsl(40 28% 93%);
}
.routine-ledger-row {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid hsl(35 21% 76% / 0.82);
border-radius: 0.75rem;
padding: 0.75rem 0.9rem;
text-decoration: none;
color: inherit;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.routine-ledger-row:hover {
transform: translateX(2px);
border-color: color-mix(in srgb, var(--page-accent) 42%, var(--border));
background: var(--page-accent-soft);
}
.health-entry-row {
border: 1px solid hsl(35 21% 76% / 0.82);
border-radius: 0.75rem;
padding: 0.8rem 0.9rem;
background: linear-gradient(165deg, hsl(44 31% 96%), hsl(42 30% 94%));
}
.health-kind-pill,
.health-flag-pill {
display: inline-flex;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.26rem 0.62rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.health-kind-pill--prescription,
.health-flag-pill--abnormal,
.health-flag-pill--high {
border-color: hsl(4 54% 70%);
background: hsl(5 58% 91%);
color: hsl(5 58% 31%);
}
.health-kind-pill--otc,
.health-flag-pill--negative {
border-color: hsl(206 40% 69%);
background: hsl(205 45% 90%);
color: hsl(208 53% 29%);
}
.health-kind-pill--supplement,
.health-kind-pill--herbal,
.health-flag-pill--normal {
border-color: hsl(136 27% 67%);
background: hsl(132 31% 90%);
color: hsl(136 49% 26%);
}
.health-kind-pill--other {
border-color: hsl(35 20% 70%);
background: hsl(40 22% 89%);
color: hsl(28 24% 29%);
}
.health-flag-pill--positive,
.health-flag-pill--low {
border-color: hsl(33 53% 67%);
background: hsl(35 55% 90%);
color: hsl(28 55% 30%);
}
[data-slot='card'] {
border-color: hsl(35 22% 75% / 0.8);
background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%));
}
[data-slot='input'] {
border-color: hsl(36 21% 74%);
background: hsl(42 28% 96%);
}
[data-slot='input']:focus-visible {
border-color: color-mix(in srgb, var(--page-accent) 58%, white);
}
[data-slot='button']:focus-visible,
[data-slot='badge']:focus-visible {
outline-color: var(--page-accent);
}
@media (min-width: 768px) {
.app-shell {
flex-direction: row;
}
.app-main {
padding: 2rem;
}
}
.editorial-dashboard {
position: relative;
margin: 0 auto;
width: min(1100px, 100%);
color: var(--editorial-ink);
}
.editorial-atmosphere {
pointer-events: none;
position: absolute;
inset: -2.5rem -1rem auto;
z-index: 0;
height: 14rem;
border-radius: 2rem;
background:
radial-gradient(
circle at 18% 34%,
color-mix(in srgb, var(--page-accent) 24%, white) 0%,
transparent 47%
),
radial-gradient(circle at 74% 16%, hsl(198 63% 85% / 0.52), transparent 39%),
linear-gradient(130deg, hsl(45 48% 94%), hsl(34 38% 91%));
filter: saturate(110%);
}
.editorial-hero,
.editorial-panel {
position: relative;
z-index: 1;
overflow: hidden;
border: 1px solid hsl(36 26% 74% / 0.8);
background: linear-gradient(160deg, hsl(44 40% 95%), var(--editorial-paper));
box-shadow:
0 24px 48px -34px hsl(219 32% 14% / 0.44),
inset 0 1px 0 hsl(0 0% 100% / 0.75);
}
.editorial-hero {
margin-bottom: 1.1rem;
border-radius: 1.5rem;
padding: clamp(1.2rem, 2.6vw, 2rem);
}
.editorial-kicker {
margin-bottom: 0.4rem;
color: var(--editorial-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.editorial-title {
margin: 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(2.2rem, 5vw, 3.6rem);
font-weight: 600;
line-height: 0.95;
letter-spacing: 0.01em;
}
.editorial-subtitle {
margin-top: 0.66rem;
max-width: 48ch;
color: var(--editorial-muted);
font-size: 0.98rem;
}
.hero-strip {
margin-top: 1.3rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
border-top: 1px dashed color-mix(in srgb, var(--page-accent) 30%, var(--editorial-line));
padding-top: 0.9rem;
}
.hero-strip-label {
margin: 0;
color: var(--editorial-muted);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.hero-strip-value {
margin: 0.22rem 0 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: 1.4rem;
font-weight: 600;
}
.editorial-grid {
position: relative;
z-index: 1;
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr);
}
.editorial-panel {
border-radius: 1.2rem;
padding: 1rem;
}
.panel-header {
margin-bottom: 0.9rem;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid hsl(36 20% 73% / 0.72);
padding-bottom: 0.6rem;
}
.panel-header h3 {
margin: 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
font-weight: 600;
}
.panel-index {
margin: 0;
color: var(--editorial-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.16em;
}
.panel-action-row {
margin-bottom: 0.7rem;
display: flex;
}
.snapshot-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.snapshot-date {
color: var(--editorial-muted);
font-size: 0.9rem;
font-weight: 600;
}
.state-pill,
.routine-pill {
display: inline-flex;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.28rem 0.68rem;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.state-pill--excellent {
border-color: hsl(145 34% 65%);
background: hsl(146 42% 90%);
color: hsl(144 48% 26%);
}
.state-pill--good {
border-color: hsl(191 44% 68%);
background: hsl(190 56% 90%);
color: hsl(193 60% 24%);
}
.state-pill--fair {
border-color: hsl(40 68% 67%);
background: hsl(44 76% 90%);
color: hsl(35 63% 30%);
}
.state-pill--poor {
border-color: hsl(4 64% 67%);
background: hsl(6 72% 89%);
color: hsl(8 64% 33%);
}
.concern-cloud {
margin-top: 0.92rem;
display: flex;
flex-wrap: wrap;
gap: 0.44rem;
}
.concern-chip {
border: 1px solid hsl(36 24% 71% / 0.88);
border-radius: 0.42rem;
background: hsl(42 36% 92%);
padding: 0.36rem 0.52rem;
color: hsl(220 20% 22%);
font-size: 0.81rem;
font-weight: 600;
}
.snapshot-notes {
margin-top: 0.9rem;
border-left: 2px solid hsl(37 34% 66% / 0.8);
padding-left: 0.8rem;
color: hsl(220 13% 34%);
font-size: 0.94rem;
line-height: 1.45;
}
.routine-list {
margin: 0;
padding: 0;
list-style: none;
}
.routine-summary-strip {
margin-bottom: 0.7rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.routine-summary-chip {
border: 1px solid hsl(35 24% 71% / 0.85);
border-radius: 999px;
padding: 0.22rem 0.62rem;
color: var(--editorial-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.panel-action-link,
.routine-summary-link {
border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line));
border-radius: 999px;
padding: 0.24rem 0.64rem;
color: var(--page-accent);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-decoration: none;
text-transform: uppercase;
}
.routine-summary-link {
margin-left: auto;
}
.panel-action-link:hover,
.routine-summary-link:hover {
background: var(--page-accent-soft);
}
.routine-item + .routine-item {
border-top: 1px dashed hsl(36 26% 72% / 0.7);
}
.routine-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.78rem 0;
text-decoration: none;
color: inherit;
transition: transform 140ms ease, color 160ms ease;
}
.routine-main {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.routine-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.routine-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--editorial-muted);
font-size: 0.8rem;
}
.routine-note-inline {
overflow: hidden;
max-width: 38ch;
white-space: nowrap;
text-overflow: ellipsis;
}
.routine-link:hover {
transform: translateX(4px);
color: var(--page-accent);
}
.routine-link:focus-visible {
outline: 2px solid var(--page-accent);
outline-offset: 3px;
border-radius: 0.4rem;
}
.routine-date {
font-size: 0.93rem;
font-weight: 600;
}
.routine-pill--am {
border-color: hsl(188 43% 66%);
background: hsl(188 52% 89%);
color: hsl(194 56% 24%);
}
.routine-pill--pm {
border-color: hsl(21 58% 67%);
background: hsl(23 68% 90%);
color: hsl(14 56% 31%);
}
.empty-copy {
margin: 0;
color: var(--editorial-muted);
font-size: 0.95rem;
}
.empty-actions {
margin-top: 0.75rem;
display: flex;
}
.reveal-1,
.reveal-2,
.reveal-3 {
opacity: 0;
transform: translateY(16px);
animation: editorial-rise 620ms cubic-bezier(0.2, 0.85, 0.24, 1) forwards;
}
.reveal-2 {
animation-delay: 90ms;
}
.reveal-3 {
animation-delay: 160ms;
}
@keyframes editorial-rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1024px) {
.editorial-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.editorial-title {
font-size: 2.05rem;
}
.panel-header {
align-items: center;
}
.panel-header h3 {
font-size: 1.4rem;
}
.state-pill,
.routine-pill {
letter-spacing: 0.08em;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-1,
.reveal-2,
.reveal-3 {
opacity: 1;
transform: none;
animation: none;
}
.routine-link {
transition: none;
}
}

View file

@ -3,6 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Infant:wght@500;600;700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -11,7 +11,6 @@ import type {
Product,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory,
Routine,
RoutineSuggestion,
@ -109,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;
@ -127,8 +127,6 @@ export interface ProductParseResponse {
product_effect_profile?: ProductEffectProfile;
ph_min?: number;
ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number;
max_frequency_per_week?: number;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Sparkles, X } from 'lucide-svelte';
let {
open = false,
aiText = $bindable(''),
aiLoading = false,
aiError = '',
textareaClass,
onClose,
onSubmit
}: {
open?: boolean;
aiText?: string;
aiLoading?: boolean;
aiError?: string;
textareaClass: string;
onClose: () => void;
onSubmit: () => void;
} = $props();
</script>
{#if open}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
onclick={onClose}
aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={onClose} aria-label={m.common_cancel()}>
<X class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent class="space-y-3 overflow-y-auto p-4">
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
<textarea bind:value={aiText} rows="8" placeholder={m["productForm_pasteText"]()} class={textareaClass}></textarea>
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={onClose} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={onSubmit} disabled={aiLoading || !aiText.trim()}>
{#if aiLoading}
{m["productForm_parsing"]()}
{:else}
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
{/if}
</Button>
</div>
</CardContent>
</Card>
</div>
{/if}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let {
title,
titleClass = 'text-base',
className,
contentClassName,
children
}: {
title: string;
titleClass?: string;
className?: string;
contentClassName?: string;
children?: import('svelte').Snippet;
} = $props();
</script>
<Card class={className}>
<CardHeader><CardTitle class={titleClass}>{title}</CardTitle></CardHeader>
<CardContent class={contentClassName}>
{@render children?.()}
</CardContent>
</Card>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { baseSelectClass } from '$lib/components/forms/form-classes';
type SelectOption = { value: string; label: string };
type SelectGroup = { label: string; options: SelectOption[] };
let {
id,
name,
label,
groups,
value = $bindable(''),
placeholder = '',
required = false,
className = '',
onChange
}: {
id: string;
name?: string;
label: string;
groups: SelectGroup[];
value?: string;
placeholder?: string;
required?: boolean;
className?: string;
onChange?: (value: string) => void;
} = $props();
const selectClass = $derived(className ? `${baseSelectClass} ${className}` : baseSelectClass);
</script>
<div class="space-y-1">
<Label for={id}>{label}</Label>
<select
{id}
{name}
class={selectClass}
bind:value
{required}
onchange={(e) => onChange?.(e.currentTarget.value)}
>
{#if placeholder}
<option value="">{placeholder}</option>
{/if}
{#each groups as group (group.label)}
<optgroup label={group.label}>
{#each group.options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</optgroup>
{/each}
</select>
</div>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
let {
id,
name,
label,
hint,
checked = $bindable(false),
disabled = false,
className = ''
}: {
id: string;
name: string;
label: string;
hint?: string;
checked?: boolean;
disabled?: boolean;
className?: string;
} = $props();
const wrapperClass = $derived(
className
? `flex items-start gap-3 rounded-md border border-border px-3 py-2 ${className}`
: 'flex items-start gap-3 rounded-md border border-border px-3 py-2'
);
</script>
<div class={wrapperClass}>
<input
{id}
{name}
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
bind:checked
{disabled}
/>
<div class="space-y-0.5">
<Label for={id} class="font-medium">{label}</Label>
{#if hint}
<p class="text-xs text-muted-foreground">{hint}</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
let {
id,
name,
label,
value = $bindable(''),
type = 'text',
placeholder,
required = false,
min,
max,
step,
className = 'space-y-1'
}: {
id: string;
name: string;
label: string;
value?: string;
type?: 'text' | 'number' | 'date' | 'url';
placeholder?: string;
required?: boolean;
min?: string;
max?: string;
step?: string;
className?: string;
} = $props();
</script>
<div class={className}>
<Label for={id}>{label}</Label>
<Input
{id}
{name}
{type}
{required}
{placeholder}
{min}
{max}
{step}
bind:value
/>
</div>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { baseSelectClass } from '$lib/components/forms/form-classes';
type SelectOption = { value: string; label: string };
let {
id,
name,
label,
options,
value = $bindable(''),
placeholder = '',
required = false,
className = '',
onChange
}: {
id: string;
name?: string;
label: string;
options: SelectOption[];
value?: string;
placeholder?: string;
required?: boolean;
className?: string;
onChange?: (value: string) => void;
} = $props();
const selectClass = $derived(className ? `${baseSelectClass} ${className}` : baseSelectClass);
</script>
<div class="space-y-1">
<Label for={id}>{label}</Label>
<select
{id}
{name}
class={selectClass}
bind:value
{required}
onchange={(e) => onChange?.(e.currentTarget.value)}
>
{#if placeholder}
<option value="">{placeholder}</option>
{/if}
{#each options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>

View file

@ -0,0 +1,5 @@
export const baseSelectClass =
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
export const baseTextareaClass =
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
type EffectField = { key: string; label: string };
type TriOption = { value: string; label: string };
let {
visible = false,
selectClass,
effectFields,
effectValues = $bindable<Record<string, number>>({}),
tristate,
ctxAfterShaving = $bindable(''),
ctxAfterAcids = $bindable(''),
ctxAfterRetinoids = $bindable(''),
ctxCompromisedBarrier = $bindable(''),
ctxLowUvOnly = $bindable(''),
fragranceFree = $bindable(''),
essentialOilsFree = $bindable(''),
alcoholDenatFree = $bindable(''),
pregnancySafe = $bindable('')
}: {
visible?: boolean;
selectClass: string;
effectFields: EffectField[];
effectValues?: Record<string, number>;
tristate: TriOption[];
ctxAfterShaving?: string;
ctxAfterAcids?: string;
ctxAfterRetinoids?: string;
ctxCompromisedBarrier?: string;
ctxLowUvOnly?: string;
fragranceFree?: string;
essentialOilsFree?: string;
alcoholDenatFree?: string;
pregnancySafe?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each effectFields as field (field.key)}
{@const key = field.key}
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
<span class="text-xs text-muted-foreground">{field.label}</span>
<input
type="range"
name="effect_{field.key}"
min="0"
max="5"
step="1"
bind:value={effectValues[key]}
class="accent-primary"
/>
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ctx_shaving_select">{m["productForm_ctxAfterShaving"]()}</Label>
<select id="ctx_shaving_select" name="ctx_safe_after_shaving" class={selectClass} bind:value={ctxAfterShaving}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_acids_select">{m["productForm_ctxAfterAcids"]()}</Label>
<select id="ctx_acids_select" name="ctx_safe_after_acids" class={selectClass} bind:value={ctxAfterAcids}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_retinoids_select">{m["productForm_ctxAfterRetinoids"]()}</Label>
<select id="ctx_retinoids_select" name="ctx_safe_after_retinoids" class={selectClass} bind:value={ctxAfterRetinoids}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_barrier_select">{m["productForm_ctxCompromisedBarrier"]()}</Label>
<select id="ctx_barrier_select" name="ctx_safe_with_compromised_barrier" class={selectClass} bind:value={ctxCompromisedBarrier}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_uv_select">{m["productForm_ctxLowUvOnly"]()}</Label>
<select id="ctx_uv_select" name="ctx_low_uv_only" class={selectClass} bind:value={ctxLowUvOnly}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</CardContent>
</Card>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fragrance_free_select">{m["productForm_fragranceFree"]()}</Label>
<select id="fragrance_free_select" name="fragrance_free" class={selectClass} bind:value={fragranceFree}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="essential_oils_free_select">{m["productForm_essentialOilsFree"]()}</Label>
<select id="essential_oils_free_select" name="essential_oils_free" class={selectClass} bind:value={essentialOilsFree}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="alcohol_denat_free_select">{m["productForm_alcoholDenatFree"]()}</Label>
<select id="alcohol_denat_free_select" name="alcohol_denat_free" class={selectClass} bind:value={alcoholDenatFree}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="pregnancy_safe_select">{m["productForm_pregnancySafe"]()}</Label>
<select id="pregnancy_safe_select" name="pregnancy_safe" class={selectClass} bind:value={pregnancySafe}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
let {
visible = true,
name = $bindable(''),
brand = $bindable(''),
lineName = $bindable(''),
url = $bindable(''),
sku = $bindable(''),
barcode = $bindable('')
}: {
visible?: boolean;
name?: string;
brand?: string;
lineName?: string;
url?: string;
sku?: string;
barcode?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
</div>
<div class="space-y-2">
<Label for="brand">{m["productForm_brand"]()}</Label>
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
</div>
<div class="space-y-2">
<Label for="url">{m["productForm_url"]()}</Label>
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
</div>
<div class="space-y-2">
<Label for="barcode">{m["productForm_barcode"]()}</Label>
<Input id="barcode" name="barcode" placeholder={m["productForm_barcodePlaceholder"]()} bind:value={barcode} />
</div>
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
let {
visible = true,
selectClass,
categories,
textures,
absorptionSpeeds,
categoryLabels,
textureLabels,
absorptionLabels,
category = $bindable(''),
recommendedTime = $bindable(''),
leaveOn = $bindable('true'),
texture = $bindable(''),
absorptionSpeed = $bindable('')
}: {
visible?: boolean;
selectClass: string;
categories: string[];
textures: string[];
absorptionSpeeds: string[];
categoryLabels: Record<string, string>;
textureLabels: Record<string, string>;
absorptionLabels: Record<string, string>;
category?: string;
recommendedTime?: string;
leaveOn?: string;
texture?: string;
absorptionSpeed?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-2 space-y-2">
<Label for="category_select">{m["productForm_category"]()}</Label>
<select id="category_select" name="category" class={selectClass} bind:value={category}>
<option value="">{m["productForm_selectCategory"]()}</option>
{#each categories as cat (cat)}
<option value={cat}>{categoryLabels[cat]}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="recommended_time_select">{m["productForm_time"]()}</Label>
<select id="recommended_time_select" name="recommended_time" class={selectClass} bind:value={recommendedTime}>
<option value="">{m["productForm_timeOptions"]()}</option>
<option value="am">{m.common_am()}</option>
<option value="pm">{m.common_pm()}</option>
<option value="both">{m["productForm_timeBoth"]()}</option>
</select>
</div>
<div class="space-y-2">
<Label for="leave_on_select">{m["productForm_leaveOn"]()}</Label>
<select id="leave_on_select" name="leave_on" class={selectClass} bind:value={leaveOn}>
<option value="true">{m["productForm_leaveOnYes"]()}</option>
<option value="false">{m["productForm_leaveOnNo"]()}</option>
</select>
</div>
<div class="space-y-2">
<Label for="texture_select">{m["productForm_texture"]()}</Label>
<select id="texture_select" name="texture" class={selectClass} bind:value={texture}>
<option value="">{m["productForm_selectTexture"]()}</option>
{#each textures as t (t)}
<option value={t}>{textureLabels[t]}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="absorption_speed_select">{m["productForm_absorptionSpeed"]()}</Label>
<select id="absorption_speed_select" name="absorption_speed" class={selectClass} bind:value={absorptionSpeed}>
<option value="">{m["productForm_selectSpeed"]()}</option>
{#each absorptionSpeeds as s (s)}
<option value={s}>{absorptionLabels[s]}</option>
{/each}
</select>
</div>
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
let {
visible = false,
textareaClass,
priceAmount = $bindable(''),
priceCurrency = $bindable('PLN'),
sizeMl = $bindable(''),
fullWeightG = $bindable(''),
emptyWeightG = $bindable(''),
paoMonths = $bindable(''),
phMin = $bindable(''),
phMax = $bindable(''),
usageNotes = $bindable(''),
minIntervalHours = $bindable(''),
maxFrequencyPerWeek = $bindable(''),
needleLengthMm = $bindable(''),
isMedication = $bindable(false),
isTool = $bindable(false),
computedPriceLabel,
computedPricePerUseLabel,
computedPriceTierLabel
}: {
visible?: boolean;
textareaClass: string;
priceAmount?: string;
priceCurrency?: string;
sizeMl?: string;
fullWeightG?: string;
emptyWeightG?: string;
paoMonths?: string;
phMin?: string;
phMax?: string;
usageNotes?: string;
minIntervalHours?: string;
maxFrequencyPerWeek?: string;
needleLengthMm?: string;
isMedication?: boolean;
isTool?: boolean;
computedPriceLabel?: string;
computedPricePerUseLabel?: string;
computedPriceTierLabel?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label for="price_amount">{m["productForm_price"]()}</Label>
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder={m["productForm_priceAmountPlaceholder"]()} bind:value={priceAmount} />
</div>
<div class="space-y-2">
<Label for="price_currency">{m["productForm_currency"]()}</Label>
<Input id="price_currency" name="price_currency" maxlength={3} placeholder={m["productForm_priceCurrencyPlaceholder"]()} bind:value={priceCurrency} />
</div>
<div class="space-y-2">
<Label for="size_ml">{m["productForm_sizeMl"]()}</Label>
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
</div>
<div class="space-y-2">
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_fullWeightPlaceholder"]()} bind:value={fullWeightG} />
</div>
<div class="space-y-2">
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_emptyWeightPlaceholder"]()} bind:value={emptyWeightG} />
</div>
<div class="space-y-2">
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
</div>
</div>
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div class="space-y-2">
<div>
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
<p class="font-medium">{computedPriceTierLabel ?? m.common_unknown()}</p>
</div>
</div>
</div>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
</div>
<div class="space-y-2">
<Label for="ph_max">{m["productForm_phMax"]()}</Label>
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
</div>
</div>
<div class="space-y-2">
<Label for="usage_notes">{m["productForm_usageNotes"]()}</Label>
<textarea
id="usage_notes"
name="usage_notes"
rows="2"
placeholder={m["productForm_usageNotesPlaceholder"]()}
class={textareaClass}
bind:value={usageNotes}
></textarea>
</div>
</CardContent>
</Card>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
</div>
<div class="space-y-2">
<Label for="max_frequency_per_week">{m["productForm_maxFrequencyPerWeek"]()}</Label>
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
</div>
</div>
<div class="flex gap-6">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_medication" value={String(isMedication)} />
<input
type="checkbox"
checked={isMedication}
onchange={() => (isMedication = !isMedication)}
class="rounded border-input"
/>
{m["productForm_isMedication"]()}
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_tool" value={String(isTool)} />
<input
type="checkbox"
checked={isTool}
onchange={() => (isTool = !isTool)}
class="rounded border-input"
/>
{m["productForm_isTool"]()}
</label>
</div>
<div class="space-y-2">
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label>
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
type TriOption = { value: string; label: string };
let {
visible = false,
selectClass,
textareaClass,
tristate,
personalRepurchaseIntent = $bindable(''),
personalToleranceNotes = $bindable('')
}: {
visible?: boolean;
selectClass: string;
textareaClass: string;
tristate: TriOption[];
personalRepurchaseIntent?: string;
personalToleranceNotes?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="repurchase_intent_select">{m["productForm_repurchaseIntent"]()}</Label>
<select id="repurchase_intent_select" name="personal_repurchase_intent" class={selectClass} bind:value={personalRepurchaseIntent}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
<textarea
id="personal_tolerance_notes"
name="personal_tolerance_notes"
rows="2"
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
class={textareaClass}
bind:value={personalToleranceNotes}
></textarea>
</div>
</CardContent>
</Card>

View file

@ -2,16 +2,16 @@
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-semibold whitespace-nowrap tracking-[0.08em] uppercase transition-[color,box-shadow,border-color,background-color] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
"bg-[var(--page-accent)] text-white [a&]:hover:brightness-95 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
outline: "border-border bg-transparent text-foreground [a&]:hover:border-[color:var(--page-accent)] [a&]:hover:bg-[var(--page-accent-soft)]",
},
},
defaultVariants: {

View file

@ -4,17 +4,17 @@
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md border text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
default: "border-transparent bg-[var(--page-accent)] text-white shadow-sm hover:brightness-95",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
"border-transparent bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-white shadow-sm",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"border-border bg-card text-card-foreground shadow-sm hover:border-[color:var(--page-accent)] hover:bg-[var(--page-accent-soft)]",
secondary: "border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-[var(--page-accent-soft)]",
link: "border-transparent px-0 text-[var(--page-accent)] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",

View file

@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-[0_18px_36px_-32px_hsl(210_24%_15%_/_0.55)]",
className
)}
{...restProps}

View file

@ -25,7 +25,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
@ -40,7 +40,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className

View file

@ -33,7 +33,6 @@ export type IngredientFunction =
| "prebiotic"
| "vitamin_c"
| "anti_aging";
export type InteractionScope = "same_step" | "same_day" | "same_period";
export type MedicationKind =
| "prescription"
| "otc"
@ -43,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"
@ -113,12 +113,6 @@ export interface ProductEffectProfile {
anti_aging_strength: number;
}
export interface ProductInteraction {
target: string;
scope: InteractionScope;
reason?: string;
}
export interface ProductContext {
safe_after_shaving?: boolean;
safe_after_acids?: boolean;
@ -154,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;
@ -172,8 +170,6 @@ export interface Product {
product_effect_profile: ProductEffectProfile;
ph_min?: number;
ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number;
max_frequency_per_week?: number;

View file

@ -4,19 +4,31 @@
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import {
House,
Package,
ClipboardList,
Scissors,
Pill,
FlaskConical,
Sparkles,
Menu,
X
} from 'lucide-svelte';
let { children } = $props();
let mobileMenuOpen = $state(false);
const domainClass = $derived(getDomainClass(page.url.pathname));
const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical },
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }
]);
function isActive(href: string) {
@ -29,24 +41,33 @@
);
return !moreSpecific;
}
function getDomainClass(pathname: string): string {
if (pathname.startsWith('/products')) return 'domain-products';
if (pathname.startsWith('/routines')) return 'domain-routines';
if (pathname.startsWith('/skin')) return 'domain-skin';
if (pathname.startsWith('/health/lab-results')) return 'domain-health-labs';
if (pathname.startsWith('/health/medications')) return 'domain-health-meds';
return 'domain-dashboard';
}
</script>
<div class="flex min-h-screen flex-col bg-background md:flex-row">
<div class="app-shell {domainClass}">
<!-- Mobile header -->
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden">
<header class="app-mobile-header md:hidden">
<div>
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
<span class="app-mobile-title">{m["nav_appName"]()}</span>
</div>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Toggle menu"
class="app-icon-button"
aria-label={m.common_toggleMenu()}
>
{#if mobileMenuOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<X class="size-[18px]" />
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<Menu class="size-[18px]" />
{/if}
</button>
</header>
@ -60,16 +81,16 @@
onclick={() => (mobileMenuOpen = false)}
aria-label={m.common_cancel()}
></button>
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
<!-- Drawer (same z-50 but later in DOM, on top) -->
<nav
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden app-sidebar"
>
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
{#each navItems as item (item.href)}
<li>
<a
href={item.href}
@ -79,7 +100,7 @@
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span class="text-base">{item.icon}</span>
<item.icon class="size-4 shrink-0" />
{item.label}
</a>
</li>
@ -92,13 +113,13 @@
{/if}
<!-- Desktop Sidebar -->
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
<nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
{#each navItems as item (item.href)}
<li>
<a
href={item.href}
@ -107,7 +128,7 @@
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span class="text-base">{item.icon}</span>
<item.icon class="size-4 shrink-0" />
{item.label}
</a>
</li>
@ -119,7 +140,7 @@
</nav>
<!-- Main content -->
<main class="flex-1 overflow-auto p-4 md:p-8">
<main class="app-main">
{@render children()}
</main>
</div>

View file

@ -1,85 +1,137 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data }: { data: PageData } = $props();
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
const stateTone: Record<string, string> = {
excellent: 'state-pill state-pill--excellent',
good: 'state-pill state-pill--good',
fair: 'state-pill state-pill--fair',
poor: 'state-pill state-pill--poor'
};
const routineTone: Record<string, string> = {
am: 'routine-pill routine-pill--am',
pm: 'routine-pill routine-pill--pm'
};
function humanize(text: string): string {
return text
.split('_')
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
.join(' ');
}
const routineStats = $derived.by(() => {
const amCount = data.recentRoutines.filter((routine) => routine.part_of_day === 'am').length;
const pmCount = data.recentRoutines.length - amCount;
return { amCount, pmCount };
});
</script>
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
<div class="space-y-8">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.dashboard_title()}</h2>
<p class="text-muted-foreground">{m.dashboard_subtitle()}</p>
</div>
<div class="editorial-dashboard">
<div class="editorial-atmosphere" aria-hidden="true"></div>
<div class="grid gap-6 md:grid-cols-2">
<!-- Latest skin snapshot -->
<Card>
<CardHeader>
<CardTitle>{m["dashboard_latestSnapshot"]()}</CardTitle>
</CardHeader>
<CardContent>
{#if data.latestSnapshot}
{@const s = data.latestSnapshot}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
{#if s.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
{s.overall_state}
</span>
{/if}
</div>
{#if s.active_concerns.length}
<div class="flex flex-wrap gap-1">
{#each s.active_concerns as concern (concern)}
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
{#if s.notes}
<p class="text-sm text-muted-foreground">{s.notes}</p>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["dashboard_noSnapshots"]()}</p>
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.dashboard_title()}</h2>
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p>
{#if data.latestSnapshot}
{@const snapshot = data.latestSnapshot}
<div class="hero-strip">
<div>
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p>
<p class="hero-strip-value">{snapshot.snapshot_date}</p>
</div>
{#if snapshot.overall_state}
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
{humanize(snapshot.overall_state)}
</span>
{/if}
</CardContent>
</Card>
</div>
{/if}
</section>
<!-- Recent routines -->
<Card>
<CardHeader>
<CardTitle>{m["dashboard_recentRoutines"]()}</CardTitle>
</CardHeader>
<CardContent>
{#if data.recentRoutines.length}
<ul class="space-y-2">
{#each data.recentRoutines as routine (routine.id)}
<li class="flex items-center justify-between">
<a href="/routines/{routine.id}" class="text-sm hover:underline">
{routine.routine_date}
</a>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</li>
<div class="editorial-grid">
<section class="editorial-panel reveal-2">
<header class="panel-header">
<p class="panel-index">01</p>
<h3>{m["dashboard_latestSnapshot"]()}</h3>
</header>
<div class="panel-action-row">
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
</div>
{#if data.latestSnapshot}
{@const s = data.latestSnapshot}
<div class="snapshot-meta-row">
<span class="snapshot-date">{s.snapshot_date}</span>
{#if s.overall_state}
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
{/if}
</div>
{#if s.active_concerns.length}
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}>
{#each s.active_concerns as concern (concern)}
<span class="concern-chip">{humanize(concern)}</span>
{/each}
</ul>
{:else}
<p class="text-sm text-muted-foreground">{m["dashboard_noRoutines"]()}</p>
</div>
{/if}
</CardContent>
</Card>
{#if s.notes}
<p class="snapshot-notes">{s.notes}</p>
{/if}
{:else}
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p>
{/if}
</section>
<section class="editorial-panel reveal-3">
<header class="panel-header">
<p class="panel-index">02</p>
<h3>{m["dashboard_recentRoutines"]()}</h3>
</header>
{#if data.recentRoutines.length}
<div class="routine-summary-strip">
<span class="routine-summary-chip">AM {routineStats.amCount}</span>
<span class="routine-summary-chip">PM {routineStats.pmCount}</span>
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
</div>
<ol class="routine-list">
{#each data.recentRoutines as routine (routine.id)}
<li class="routine-item">
<a href={resolve(`/routines/${routine.id}`)} class="routine-link">
<div class="routine-main">
<div class="routine-topline">
<span class="routine-date">{routine.routine_date}</span>
<span class={routineTone[routine.part_of_day] ?? 'routine-pill'}>
{routine.part_of_day.toUpperCase()}
</span>
</div>
<div class="routine-meta">
<span>{routine.steps?.length ?? 0} {m.common_steps()}</span>
{#if routine.notes}
<span class="routine-note-inline">{m.routines_notes()}: {routine.notes}</span>
{/if}
</div>
</div>
</a>
</li>
{/each}
</ol>
{:else}
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p>
<div class="empty-actions">
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
</div>
{/if}
</section>
</div>
</div>

View file

@ -5,10 +5,11 @@
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { baseSelectClass } from '$lib/components/forms/form-classes';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import {
Table,
TableBody,
@ -21,75 +22,74 @@
let { data, form }: { data: PageData; form: ActionData } = $props();
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
const flagColors: Record<string, string> = {
N: 'bg-green-100 text-green-800',
ABN: 'bg-red-100 text-red-800',
POS: 'bg-orange-100 text-orange-800',
NEG: 'bg-blue-100 text-blue-800',
L: 'bg-yellow-100 text-yellow-800',
H: 'bg-red-100 text-red-800'
const flagPills: Record<string, string> = {
N: 'health-flag-pill health-flag-pill--normal',
ABN: 'health-flag-pill health-flag-pill--abnormal',
POS: 'health-flag-pill health-flag-pill--positive',
NEG: 'health-flag-pill health-flag-pill--negative',
L: 'health-flag-pill health-flag-pill--low',
H: 'health-flag-pill health-flag-pill--high'
};
let showForm = $state(false);
let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? '');
const flagOptions = flags.map((f) => ({ value: f, label: f }));
function onFlagChange(v: string) {
const base = resolve('/health/lab-results');
const url = v ? base + '?flag=' + v : base;
goto(url, { replaceState: true });
const target = v ? `${base}?flag=${encodeURIComponent(v)}` : base;
goto(target, { replaceState: true });
}
</script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m["labResults_title"]()}</h2>
<p class="text-muted-foreground">{m["labResults_count"]({ count: data.results.length })}</p>
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["labResults_title"]()}</h2>
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.results.length })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/health/medications')} variant="outline">{m.medications_title()}</Button>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
</Button>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["labResults_added"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
{/if}
<!-- Filter -->
<div class="flex items-center gap-3">
<div class="editorial-panel reveal-2 flex items-center gap-3">
<span class="text-sm text-muted-foreground">{m["labResults_flagFilter"]()}</span>
<Select
type="single"
<select
class={`${baseSelectClass} w-32`}
value={filterFlag}
onValueChange={onFlagChange}
onchange={(e) => onFlagChange(e.currentTarget.value)}
>
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
<SelectContent>
<SelectItem value="">{m["labResults_flagAll"]()}</SelectItem>
{#each flags as f (f)}
<SelectItem value={f}>{f}</SelectItem>
{/each}
</SelectContent>
</Select>
<option value="">{m["labResults_flagAll"]()}</option>
{#each flags as f (f)}
<option value={f}>{f}</option>
{/each}
</select>
</div>
{#if showForm}
<Card>
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label>
<Input id="collected_at" name="collected_at" type="date" required />
</div>
<div class="space-y-1">
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">(e.g. 718-7)</span></Label>
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">({m["labResults_loincExample"]()})</span></Label>
<Input id="test_code" name="test_code" required placeholder="718-7" />
</div>
<div class="space-y-1">
@ -108,29 +108,23 @@
<Label for="unit_original">{m["labResults_unit"]()}</Label>
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
</div>
<div class="space-y-1">
<Label>{m["labResults_flag"]()}</Label>
<input type="hidden" name="flag" value={selectedFlag} />
<Select type="single" value={selectedFlag} onValueChange={(v) => (selectedFlag = v)}>
<SelectTrigger>{selectedFlag || m["labResults_flagNone"]()}</SelectTrigger>
<SelectContent>
<SelectItem value="">{m["labResults_flagNone"]()}</SelectItem>
{#each flags as f (f)}
<SelectItem value={f}>{f}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<SimpleSelect
id="flag"
name="flag"
label={m["labResults_flag"]()}
options={flagOptions}
placeholder={m["labResults_flagNone"]()}
bind:value={selectedFlag}
/>
<div class="flex items-end">
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
</FormSectionCard>
{/if}
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<div class="products-table-shell hidden md:block reveal-2">
<Table>
<TableHeader>
<TableRow>
@ -159,7 +153,7 @@
</TableCell>
<TableCell>
{#if r.flag}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{:else}
@ -180,13 +174,13 @@
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
<div class="flex flex-col gap-3 md:hidden reveal-3">
{#each data.results as r (r.record_id)}
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
<div class="products-mobile-card flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
{#if r.flag}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{/if}

View file

@ -1,13 +1,14 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -15,12 +16,12 @@
let showForm = $state(false);
let kind = $state('supplement');
const kindColors: Record<string, string> = {
prescription: 'bg-purple-100 text-purple-800',
otc: 'bg-blue-100 text-blue-800',
supplement: 'bg-green-100 text-green-800',
herbal: 'bg-emerald-100 text-emerald-800',
other: 'bg-gray-100 text-gray-700'
const kindPills: Record<string, string> = {
prescription: 'health-kind-pill health-kind-pill--prescription',
otc: 'health-kind-pill health-kind-pill--otc',
supplement: 'health-kind-pill health-kind-pill--supplement',
herbal: 'health-kind-pill health-kind-pill--herbal',
other: 'health-kind-pill health-kind-pill--other'
};
const kindLabels: Record<string, () => string> = {
@ -30,44 +31,43 @@
herbal: m["medications_kindHerbal"],
other: m["medications_kindOther"]
};
const kindOptions = $derived(kinds.map((k) => ({ value: k, label: kindLabels[k]?.() ?? k })));
</script>
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.medications_title()}</h2>
<p class="text-muted-foreground">{m.medications_count({ count: data.medications.length })}</p>
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.medications_title()}</h2>
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/health/lab-results')} variant="outline">{m["labResults_title"]()}</Button>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["medications_addNew"]()}
</Button>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["medications_addNew"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.medications_added()}</div>
<div class="editorial-alert editorial-alert--success">{m.medications_added()}</div>
{/if}
{#if showForm}
<Card>
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1 col-span-2">
<Label>{m.medications_kind()}</Label>
<input type="hidden" name="kind" value={kind} />
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
<SelectTrigger>{kindLabels[kind]?.() ?? kind}</SelectTrigger>
<SelectContent>
{#each kinds as k (k)}
<SelectItem value={k}>{kindLabels[k]?.() ?? k}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="col-span-2">
<SimpleSelect
id="kind"
name="kind"
label={m.medications_kind()}
options={kindOptions}
bind:value={kind}
/>
</div>
<div class="space-y-1">
<Label for="product_name">{m["medications_productName"]()}</Label>
@ -85,16 +85,15 @@
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
</FormSectionCard>
{/if}
<div class="space-y-3">
<div class="editorial-panel reveal-2 space-y-3">
{#each data.medications as med (med.record_id)}
<div class="rounded-md border border-border px-4 py-3">
<div class="health-entry-row">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {kindColors[med.kind] ?? ''}">
<span class={kindPills[med.kind] ?? 'health-kind-pill'}>
{kindLabels[med.kind]?.() ?? med.kind}
</span>
<span class="font-medium">{med.product_name}</span>

View file

@ -6,6 +6,8 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Sparkles, ArrowUp, ArrowDown } from 'lucide-svelte';
import {
Table,
TableBody,
@ -18,7 +20,11 @@
let { data }: { data: PageData } = $props();
type OwnershipFilter = 'all' | 'owned' | 'unowned';
type SortKey = 'brand' | 'name' | 'time' | 'price';
let ownershipFilter = $state<OwnershipFilter>('all');
let sortKey = $state<SortKey>('brand');
let sortDirection = $state<'asc' | 'desc'>('asc');
let searchQuery = $state('');
const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
@ -29,14 +35,59 @@
return p.inventory?.some(inv => !inv.finished_at) ?? false;
}
function setSort(nextKey: SortKey): void {
if (sortKey === nextKey) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = nextKey;
sortDirection = 'asc';
}
function compareText(a: string, b: string): number {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
}
function comparePrice(a?: number, b?: number): number {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
}
function sortState(key: SortKey): '' | 'asc' | 'desc' {
if (sortKey !== key) return '';
return sortDirection;
}
const groupedProducts = $derived((() => {
let items = data.products;
if (ownershipFilter === 'owned') items = items.filter(isOwned);
if (ownershipFilter === 'unowned') items = items.filter(p => !isOwned(p));
const q = searchQuery.trim().toLocaleLowerCase();
if (q) {
items = items.filter((p) => {
const inName = p.name.toLocaleLowerCase().includes(q);
const inBrand = p.brand.toLocaleLowerCase().includes(q);
const inTargets = p.targets.some((t) => t.replace(/_/g, ' ').toLocaleLowerCase().includes(q));
return inName || inBrand || inTargets;
});
}
items = [...items].sort((a, b) => {
const bc = a.brand.localeCompare(b.brand);
return bc !== 0 ? bc : a.name.localeCompare(b.name);
let cmp = 0;
if (sortKey === 'brand') cmp = compareText(a.brand, b.brand);
if (sortKey === 'name') cmp = compareText(a.name, b.name);
if (sortKey === 'time') cmp = compareText(a.recommended_time, b.recommended_time);
if (sortKey === 'price') cmp = comparePrice(getPricePerUse(a), getPricePerUse(b));
if (cmp === 0) {
const byBrand = compareText(a.brand, b.brand);
cmp = byBrand !== 0 ? byBrand : compareText(a.name, b.name);
}
return sortDirection === 'asc' ? cmp : -cmp;
});
const map = new SvelteMap<string, Product[]>();
@ -55,63 +106,116 @@
})());
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
function formatPricePerUse(value?: number): string {
if (value == null) return '-';
return `${value.toFixed(2)} ${m.common_pricePerUse()}`;
}
function formatTier(value?: string): string {
if (!value) return m.common_unknown();
if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid']();
if (value === 'premium') return m['productForm_pricePremium']();
if (value === 'luxury') return m['productForm_priceLuxury']();
return value;
}
function getPricePerUse(product: Product): number | undefined {
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
}
function formatCategory(value: string): string {
return value.replace(/_/g, ' ');
}
</script>
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div>
<div class="flex gap-2">
<Button href={resolve('/products/suggest')} variant="outline">{m["products_suggest"]()}</Button>
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.products_title()}</h2>
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div>
</div>
</section>
<div class="flex flex-wrap gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
size="sm"
onclick={() => ownershipFilter = f}
>
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
</Button>
{/each}
<div class="editorial-panel reveal-2">
<div class="editorial-filter-row">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
size="sm"
onclick={() => ownershipFilter = f}
>
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
</Button>
{/each}
</div>
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="w-full lg:max-w-md">
<Input
type="search"
bind:value={searchQuery}
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
/>
</div>
<div class="flex flex-wrap gap-1">
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
{m['products_colBrand']()}
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
{m['products_colName']()}
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
{m['products_colTime']()}
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
{m["products_colPricePerUse"]()}
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
</div>
</div>
</div>
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<div class="products-table-shell hidden md:block reveal-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m["products_colName"]()}</TableHead>
<TableHead>{m["products_colBrand"]()}</TableHead>
<TableHead>{m["products_colTargets"]()}</TableHead>
<TableHead>{m["products_colTime"]()}</TableHead>
<TableRow>
<TableHead>{m["products_colName"]()}</TableHead>
<TableHead>{m["products_colBrand"]()}</TableHead>
<TableHead>{m["products_colTargets"]()}</TableHead>
<TableHead>{m["products_colTime"]()}</TableHead>
<TableHead>{m["products_colPricePerUse"]()}</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">
{category.replace(/_/g, ' ')}
<TableRow class="products-category-row">
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{formatCategory(category)}
</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">
<TableRow class={`cursor-pointer ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/25 text-muted-foreground hover:bg-muted/35'}`}>
<TableCell class="max-w-[32rem] align-top whitespace-normal">
<a href={resolve(`/products/${product.id}`)} title={product.name} class="block break-words line-clamp-2 font-medium hover:underline">
{product.name}
</a>
</TableCell>
@ -127,6 +231,12 @@
</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="text-[10px]">{formatTier(product.price_tier)}</Badge>
</div>
</TableCell>
</TableRow>
{/each}
{/each}
@ -136,23 +246,27 @@
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
<div class="flex flex-col gap-3 md:hidden reveal-3">
{#if totalCount === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else}
{#each groupedProducts as [category, products] (category)}
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category.replace(/_/g, ' ')}
<div class="products-section-title">
{formatCategory(category)}
</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={`products-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<div class="min-w-0">
<p class="break-words line-clamp-2 font-medium" title={product.name}>{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="text-[10px]">{formatTier(product.price_tier)}</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;
@ -166,23 +168,6 @@ export const actions: Actions = {
body.actives = null;
}
// Incompatible with
try {
const raw = form.get('incompatible_with_json') as string | null;
if (raw) {
const parsed = JSON.parse(raw);
body.incompatible_with = Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
} else {
body.incompatible_with = null;
}
} catch {
body.incompatible_with = null;
}
// Synergizes with
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
body.synergizes_with = synergizes.length > 0 ? synergizes : null;
// Context rules
body.context_rules = parseContextRules(form) ?? null;

View file

@ -5,10 +5,11 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -16,252 +17,330 @@
let showInventoryForm = $state(false);
let editingInventoryId = $state<string | null>(null);
let activeTab = $state<'inventory' | 'edit'>('edit');
let isEditDirty = $state(false);
let editSaveVersion = $state(0);
let productFormRef: { openAiModal: () => void } | null = $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)} ${m.common_pricePerUse()}`;
}
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 formatTier(value?: string): string {
if (!value) return m.common_unknown();
if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid']();
if (value === 'premium') return m['productForm_pricePremium']();
if (value === 'luxury') return m['productForm_priceLuxury']();
return value;
}
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div>
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
</div>
<div class="products-sticky-actions fixed inset-x-3 bottom-3 z-40 flex items-center justify-end gap-2 rounded-lg border border-border bg-card/95 p-2 shadow-sm backdrop-blur md:inset-x-auto md:bottom-auto md:right-6 md:top-4">
<Button
type="submit"
form="product-edit-form"
disabled={activeTab !== 'edit' || !isEditDirty}
size="sm"
>
<Save class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span>
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">
<Trash2 class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_deleteProduct"]()}</span>
</Button>
</form>
</div>
<div class="editorial-page space-y-4 pb-20 md:pb-0">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="break-words editorial-title">{product.name}</h2>
<div class="products-meta-strip">
<span class="font-medium text-foreground">{product.brand}</span>
<span></span>
<span class="uppercase">{product.recommended_time}</span>
<span></span>
<span>{product.category.replace(/_/g, ' ')}</span>
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.success}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
<div class="editorial-alert editorial-alert--success">{m.common_saved()}</div>
{/if}
<!-- Edit form -->
<form method="POST" action="?/update" use:enhance class="space-y-6">
<ProductForm {product} />
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
<Boxes class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m.inventory_title({ count: product.inventory.length })}</span>
</TabsTrigger>
<TabsTrigger value="edit" class="shrink-0 px-3" title={m.common_edit()}>
<Pencil class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m.common_edit()}</span>
</TabsTrigger>
</TabsList>
<div class="flex gap-3">
<Button type="submit">{m["products_saveChanges"]()}</Button>
</div>
</form>
<TabsContent value="inventory" class="space-y-4 pt-2">
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
</Button>
</div>
<Separator />
{#if form?.inventoryAdded}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div>
{/if}
{#if form?.inventoryUpdated}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div>
{/if}
{#if form?.inventoryDeleted}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
{/if}
<!-- Inventory -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
</Button>
</div>
{#if showInventoryForm}
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
</CardHeader>
<CardContent>
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div>
<div class="space-y-1">
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
<Input id="add_opened_at" name="opened_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
<Input id="add_finished_at" name="finished_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="add_expiry_date" name="expiry_date" type="date" />
</div>
<div class="space-y-1">
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
</div>
<div class="space-y-1">
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" />
</div>
<div class="flex items-end">
<Button type="submit" size="sm">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
{#if form?.inventoryAdded}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageAdded"]()}</div>
{/if}
{#if form?.inventoryUpdated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageUpdated"]()}</div>
{/if}
{#if form?.inventoryDeleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageDeleted"]()}</div>
{/if}
{#if product.inventory.length}
<div class="space-y-2">
{#each product.inventory as pkg (pkg.id)}
<div class="rounded-md border border-border text-sm">
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
</Badge>
{#if pkg.finished_at}
<Badge variant="outline">{m["inventory_badgeFinished"]()}</Badge>
{/if}
{#if pkg.expiry_date}
<span class="text-muted-foreground">{m.inventory_exp()} {pkg.expiry_date.slice(0, 10)}</span>
{/if}
{#if pkg.opened_at}
<span class="text-muted-foreground">{m.inventory_opened()} {pkg.opened_at.slice(0, 10)}</span>
{/if}
{#if pkg.finished_at}
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
{/if}
{#if pkg.current_weight_g}
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
{/if}
{#if pkg.last_weighed_at}
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
{/if}
{#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
>
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
</Button>
<form
method="POST"
action="?/deleteInventory"
use:enhance
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
</form>
</div>
</div>
{#if showInventoryForm}
{#if editingInventoryId === pkg.id}
<div class="border-t px-4 py-3">
<form
method="POST"
action="?/updateInventory"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') editingInventoryId = null;
};
}}
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<div class="sm:col-span-2 flex items-center gap-2">
<input
type="checkbox"
id="edit_is_opened_{pkg.id}"
name="is_opened"
value="true"
checked={pkg.is_opened}
class="h-4 w-4"
/>
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
</div>
<div class="space-y-1">
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
<Input id="edit_opened_at_{pkg.id}" name="opened_at" type="date" value={pkg.opened_at?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
<Input id="edit_finished_at_{pkg.id}" name="finished_at" type="date" value={pkg.finished_at?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
</div>
<div class="flex items-end gap-2">
<Button type="submit" size="sm">{m.common_save()}</Button>
<Button type="button" variant="ghost" size="sm" onclick={() => (editingInventoryId = null)}>{m.common_cancel()}</Button>
</div>
</form>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
{/if}
</div>
</TabsContent>
<TabsContent value="edit" class="space-y-4 pt-2">
<Card>
<CardContent class="pt-4">
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
<div class="col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div>
<div class="space-y-1">
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
<Input id="add_opened_at" name="opened_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
<Input id="add_finished_at" name="finished_at" type="date" />
</div>
<div class="space-y-1">
<Label for="expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="expiry_date" name="expiry_date" type="date" />
</div>
<div class="space-y-1">
<Label for="current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
</div>
<div class="space-y-1">
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
</div>
<div class="space-y-1">
<Label for="notes">{m.inventory_notes()}</Label>
<Input id="notes" name="notes" />
</div>
<div class="flex items-end">
<Button type="submit" size="sm">{m.common_add()}</Button>
</div>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardTitle class="text-base">{m.common_edit()}</CardTitle>
<Button type="button" variant="outline" size="sm" onclick={() => productFormRef?.openAiModal()}>
<Sparkles class="size-4" />
{m["productForm_aiPrefill"]()}
</Button>
</div>
</CardHeader>
<CardContent>
<form
id="product-edit-form"
method="POST"
action="?/update"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
isEditDirty = false;
editSaveVersion += 1;
}
};
}}
class="space-y-6"
>
<ProductForm
bind:this={productFormRef}
{product}
onDirtyChange={(dirty) => (isEditDirty = dirty)}
saveVersion={editSaveVersion}
showAiTrigger={false}
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
computedPricePerUseLabel={formatPricePerUse(getPricePerUse())}
computedPriceTierLabel={formatTier(product.price_tier)}
/>
</form>
</CardContent>
</Card>
{/if}
{#if product.inventory.length}
<div class="space-y-2">
{#each product.inventory as pkg (pkg.id)}
<div class="rounded-md border border-border text-sm">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
</Badge>
{#if pkg.finished_at}
<Badge variant="outline">{m["inventory_badgeFinished"]()}</Badge>
{/if}
{#if pkg.expiry_date}
<span class="text-muted-foreground">{m.inventory_exp()} {pkg.expiry_date.slice(0, 10)}</span>
{/if}
{#if pkg.opened_at}
<span class="text-muted-foreground">{m.inventory_opened()} {pkg.opened_at.slice(0, 10)}</span>
{/if}
{#if pkg.finished_at}
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
{/if}
{#if pkg.current_weight_g}
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
{/if}
{#if pkg.last_weighed_at}
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
{/if}
{#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
>
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
</Button>
<form
method="POST"
action="?/deleteInventory"
use:enhance
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">×</Button>
</form>
</div>
</div>
{#if editingInventoryId === pkg.id}
<div class="border-t px-4 py-3">
<form
method="POST"
action="?/updateInventory"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') editingInventoryId = null;
};
}}
class="grid grid-cols-2 gap-4"
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<div class="col-span-2 flex items-center gap-2">
<input
type="checkbox"
id="edit_is_opened_{pkg.id}"
name="is_opened"
value="true"
checked={pkg.is_opened}
class="h-4 w-4"
/>
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
</div>
<div class="space-y-1">
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
<Input
id="edit_opened_at_{pkg.id}"
name="opened_at"
type="date"
value={pkg.opened_at?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
<Input
id="edit_finished_at_{pkg.id}"
name="finished_at"
type="date"
value={pkg.finished_at?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
<Input
id="edit_expiry_{pkg.id}"
name="expiry_date"
type="date"
value={pkg.expiry_date?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
<Input
id="edit_weight_{pkg.id}"
name="current_weight_g"
type="number"
min="0"
value={pkg.current_weight_g ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
<Input
id="edit_last_weighed_{pkg.id}"
name="last_weighed_at"
type="date"
value={pkg.last_weighed_at?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
</div>
<div class="flex items-end gap-2">
<Button type="submit" size="sm">{m.common_save()}</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = null)}
>{m.common_cancel()}</Button>
</div>
</form>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
{/if}
</div>
<Separator />
<!-- Danger zone -->
<div>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
</form>
</div>
</TabsContent>
</Tabs>
</div>

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;
@ -167,19 +170,6 @@ export const actions: Actions = {
}
} catch { /* ignore malformed JSON */ }
// Incompatible with (JSON array)
try {
const raw = form.get('incompatible_with_json') as string | null;
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) payload.incompatible_with = parsed;
}
} catch { /* ignore */ }
// Synergizes with
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
if (synergizes.length > 0) payload.synergizes_with = synergizes;
// Context rules
const contextRules = parseContextRules(form);
if (contextRules) payload.context_rules = contextRules;

View file

@ -4,6 +4,7 @@
import type { ActionData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte';
let { form }: { form: ActionData } = $props();
@ -19,19 +20,20 @@
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["products_newTitle"]()}</h2>
</section>
{#if form?.error}
<div bind:this={errorEl} class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div bind:this={errorEl} class="editorial-alert editorial-alert--error">
{form.error}
</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<form method="POST" use:enhance class="editorial-panel reveal-2 space-y-6 p-4">
<ProductForm />
<div class="flex gap-3 pb-6">

View file

@ -6,6 +6,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Sparkles, ArrowLeft } from 'lucide-svelte';
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
@ -31,17 +32,18 @@
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
</section>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
{/if}
<Card>
<Card class="reveal-2">
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
@ -53,7 +55,7 @@
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["products_suggestGenerating"]()}
{:else}
{m["products_suggestBtn"]()}
<Sparkles class="size-4" /> {m["products_suggestBtn"]()}
{/if}
</Button>
</form>
@ -62,14 +64,14 @@
{#if suggestions && suggestions.length > 0}
{#if reasoning}
<Card class="border-muted bg-muted/30">
<Card class="border-muted bg-muted/30 reveal-3">
<CardContent class="pt-4">
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
<div class="space-y-4 reveal-3">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>

View file

@ -22,28 +22,27 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.routines_title()}</h2>
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
</div>
</div>
</section>
{#if sortedDates.length}
<div class="space-y-4">
{#each sortedDates as date}
<div class="editorial-panel reveal-2 space-y-4">
{#each sortedDates as date (date)}
<div>
<h3 class="text-sm font-semibold text-muted-foreground mb-2">{date}</h3>
<h3 class="products-section-title mb-2">{date}</h3>
<div class="space-y-1">
{#each byDate[date] as routine}
{#each byDate[date] as routine (routine.id)}
<a
href="/routines/{routine.id}"
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors"
href={resolve(`/routines/${routine.id}`)}
class="routine-ledger-row"
>
<div class="flex items-center gap-3">
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
@ -61,6 +60,8 @@
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_noRoutines"]()}</p>
<div class="editorial-panel reveal-2">
<p class="empty-copy">{m["routines_noRoutines"]()}</p>
</div>
{/if}
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types';
@ -10,16 +11,11 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select';
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data);
@ -136,21 +132,36 @@
.filter((c) => groups.has(c))
.map((c) => [c, groups.get(c)!] as const);
});
const groupedProductOptions = $derived(
groupedProducts.map(([cat, items]) => ({
label: formatCategory(cat),
options: items.map((p) => ({ value: p.id, label: `${p.name} · ${p.brand}` }))
}))
);
const groomingActionOptions = GROOMING_ACTIONS.map((action) => ({
value: action,
label: action.replace(/_/g, ' ')
}));
</script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<div class="flex items-center gap-3">
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if routine.notes}
@ -158,7 +169,7 @@
{/if}
<!-- Steps -->
<div class="space-y-3">
<div class="editorial-panel reveal-2 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
@ -171,29 +182,14 @@
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<input type="hidden" name="product_id" value={selectedProductId} />
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
<SelectTrigger>
{#if selectedProductId}
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<GroupedSelect
id="new_step_product"
name="product_id"
label={m.routines_product()}
groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
bind:value={selectedProductId}
/>
<input type="hidden" name="order_index" value={nextOrderIndex} />
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
@ -225,32 +221,14 @@
<div class="px-4 py-3 space-y-3">
{#if step.product_id !== undefined}
<!-- Product step: change product / dose / region -->
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<Select
type="single"
value={editDraft.product_id ?? ''}
onValueChange={(v) => (editDraft.product_id = v || undefined)}
>
<SelectTrigger>
{#if editDraft.product_id}
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<GroupedSelect
id={`edit_step_product_${step.id}`}
label={m.routines_product()}
groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
value={editDraft.product_id ?? ''}
onChange={(value) => (editDraft.product_id = value || undefined)}
/>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label>{m.routines_dose()}</Label>
@ -271,30 +249,21 @@
</div>
{:else}
<!-- Action step: change action_type / notes -->
<SimpleSelect
id={`edit_step_action_${step.id}`}
label={m["routines_action"]()}
options={groomingActionOptions}
placeholder={m["routines_selectAction"]()}
value={editDraft.action_type ?? ''}
onChange={(value) =>
(editDraft.action_type = (value || undefined) as GroomingAction | undefined)}
/>
<div class="space-y-1">
<Label>Action</Label>
<Select
type="single"
value={editDraft.action_type ?? ''}
onValueChange={(v) =>
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
>
<SelectTrigger>
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
</SelectTrigger>
<SelectContent>
{#each GROOMING_ACTIONS as action (action)}
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>Notes</Label>
<Label>{m.routines_notes()}</Label>
<Input
value={editDraft.action_notes ?? ''}
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
placeholder="optional notes"
placeholder={m["routines_actionNotesPlaceholder"]()}
/>
</div>
{/if}
@ -318,8 +287,8 @@
<span
use:dragHandle
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
aria-label="drag to reorder"
>⋮⋮</span>
aria-label={m.common_dragToReorder()}
><GripVertical class="size-4" /></span>
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
<div class="flex-1 min-w-0 px-1">
{#if step.product_id}
@ -340,16 +309,16 @@
size="sm"
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onclick={() => startEdit(step)}
aria-label="edit step"
></Button>
aria-label={m.common_editStep()}
><Pencil class="size-4" /></Button>
<form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} />
<Button
type="submit"
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
>×</Button>
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
><X class="size-4" /></Button>
</form>
</div>
{/if}
@ -361,7 +330,7 @@
{/if}
</div>
<Separator />
<Separator class="opacity-50" />
<form
method="POST"

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
@ -8,6 +9,7 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { ArrowLeft } from 'lucide-svelte';
import type { GroomingAction, GroomingSchedule } from '$lib/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -45,28 +47,31 @@
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1">
<div class="flex items-center justify-between">
<div>
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["grooming_backToRoutines"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2>
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
<p class="editorial-kicker mt-2">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title mt-1 text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
</div>
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryAdded"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryAdded"]()}</div>
{/if}
{#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryUpdated"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryUpdated"]()}</div>
{/if}
{#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryDeleted"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryDeleted"]()}</div>
{/if}
<!-- Add form -->

View file

@ -1,49 +1,51 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
let partOfDay = $state('am');
const partOfDayOptions = [
{ value: 'am', label: m.common_am() },
{ value: 'pm', label: m.common_pm() }
];
</script>
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-md space-y-6">
<div class="flex items-center gap-4">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
<Card>
<CardHeader><CardTitle>{m["routines_detailsTitle"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["routines_detailsTitle"]()} className="reveal-2">
<form method="POST" use:enhance class="space-y-5">
<div class="space-y-2">
<Label for="routine_date">{m.routines_date()}</Label>
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
</div>
<div class="space-y-2">
<Label>{m["routines_amOrPm"]()}</Label>
<input type="hidden" name="part_of_day" value={partOfDay} />
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
<SelectContent>
<SelectItem value="am">AM</SelectItem>
<SelectItem value="pm">PM</SelectItem>
</SelectContent>
</Select>
</div>
<SimpleSelect
id="part_of_day"
name="part_of_day"
label={m["routines_amOrPm"]()}
options={partOfDayOptions}
bind:value={partOfDay}
/>
<div class="space-y-2">
<Label for="notes">{m.routines_notes()}</Label>
@ -52,9 +54,8 @@
<div class="flex gap-3 pt-2">
<Button type="submit">{m["routines_createRoutine"]()}</Button>
<Button variant="outline" href="/routines">{m.common_cancel()}</Button>
<Button variant="outline" href={resolve('/routines')}>{m.common_cancel()}</Button>
</div>
</form>
</CardContent>
</Card>
</FormSectionCard>
</div>

View file

@ -7,11 +7,15 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Card, CardContent } from '$lib/components/ui/card';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { baseTextareaClass } from '$lib/components/forms/form-classes';
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
@ -33,6 +37,13 @@
let loadingBatch = $state(false);
let loadingSave = $state(false);
const textareaClass = `${baseTextareaClass} resize-none`;
const partOfDayOptions = [
{ value: 'am', label: m["suggest_amMorning"]() },
{ value: 'pm', label: m["suggest_pmEvening"]() }
];
function stepLabel(step: SuggestedStep): string {
if (step.product_id && productMap[step.product_id]) {
const p = productMap[step.product_id];
@ -103,17 +114,18 @@
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.suggest_title()}</h2>
</section>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
{/if}
<Tabs value="single">
<Tabs value="single" class="reveal-2 editorial-tabs">
<TabsList class="w-full">
<TabsTrigger value="single" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_singleTab"]()}</TabsTrigger>
<TabsTrigger value="batch" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_batchTab"]()}</TabsTrigger>
@ -121,26 +133,20 @@
<!-- ── Single tab ─────────────────────────────────────────────────── -->
<TabsContent value="single" class="space-y-6 pt-4">
<Card>
<CardHeader><CardTitle class="text-base">{m["suggest_singleParams"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["suggest_singleParams"]()}>
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="single_date">{m.suggest_date()}</Label>
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
</div>
<div class="space-y-2">
<Label>{m["suggest_timeOfDay"]()}</Label>
<input type="hidden" name="part_of_day" value={partOfDay} />
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}>
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
<SelectContent>
<SelectItem value="am">{m["suggest_amMorning"]()}</SelectItem>
<SelectItem value="pm">{m["suggest_pmEvening"]()}</SelectItem>
</SelectContent>
</Select>
</div>
<SimpleSelect
id="single_part_of_day"
name="part_of_day"
label={m["suggest_timeOfDay"]()}
options={partOfDayOptions}
bind:value={partOfDay}
/>
</div>
<div class="space-y-2">
@ -150,35 +156,23 @@
name="notes"
rows="2"
placeholder={m["suggest_contextPlaceholder"]()}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
class={textareaClass}
></textarea>
</div>
{#if partOfDay === 'am'}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
<HintCheckbox
id="single_leaving_home"
name="leaving_home"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
label={m["suggest_leavingHomeLabel"]()}
hint={m["suggest_leavingHomeHint"]()}
/>
<div class="space-y-0.5">
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
</div>
</div>
{/if}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<HintCheckbox
id="single_include_minoxidil_beard"
name="include_minoxidil_beard"
label={m["suggest_minoxidilToggleLabel"]()}
hint={m["suggest_minoxidilToggleHint"]()}
/>
<Button type="submit" disabled={loadingSingle} class="w-full">
{#if loadingSingle}
@ -189,8 +183,7 @@
{/if}
</Button>
</form>
</CardContent>
</Card>
</FormSectionCard>
{#if suggestion}
<div class="space-y-4">
@ -271,9 +264,7 @@
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
<TabsContent value="batch" class="space-y-6 pt-4">
<Card>
<CardHeader><CardTitle class="text-base">{m["suggest_batchRange"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["suggest_batchRange"]()}>
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
@ -293,33 +284,21 @@
name="notes"
rows="2"
placeholder={m["suggest_batchContextPlaceholder"]()}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
class={textareaClass}
></textarea>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_minimize_products"
name="minimize_products"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
</div>
</div>
<HintCheckbox
id="batch_include_minoxidil_beard"
name="include_minoxidil_beard"
label={m["suggest_minoxidilToggleLabel"]()}
hint={m["suggest_minoxidilToggleHint"]()}
/>
<HintCheckbox
id="batch_minimize_products"
name="minimize_products"
label={m["suggest_minimizeProductsLabel"]()}
hint={m["suggest_minimizeProductsHint"]()}
/>
<Button type="submit" disabled={loadingBatch} class="w-full">
{#if loadingBatch}
@ -330,8 +309,7 @@
{/if}
</Button>
</form>
</CardContent>
</Card>
</FormSectionCard>
{#if batch}
<div class="space-y-4">
@ -359,9 +337,13 @@
<span class="font-medium text-sm">{day.date}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
AM {day.am_steps.length} {m["suggest_amSteps"]()} · PM {day.pm_steps.length} {m["suggest_pmSteps"]()}
{m.common_am()} {day.am_steps.length} {m["suggest_amSteps"]()} · {m.common_pm()} {day.pm_steps.length} {m["suggest_pmSteps"]()}
</span>
<span class="text-muted-foreground">{isOpen ? '▲' : '▼'}</span>
{#if isOpen}
<ChevronUp class="size-4 text-muted-foreground" />
{:else}
<ChevronDown class="size-4 text-muted-foreground" />
{/if}
</div>
</button>
@ -374,7 +356,7 @@
<!-- AM steps -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge>AM</Badge>
<Badge>{m.common_am()}</Badge>
<span class="text-xs text-muted-foreground">{day.am_steps.length} {m["suggest_amSteps"]()}</span>
</div>
{#if day.am_steps.length}
@ -399,7 +381,7 @@
<!-- PM steps -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge variant="secondary">PM</Badge>
<Badge variant="secondary">{m.common_pm()}</Badge>
<span class="text-xs text-muted-foreground">{day.pm_steps.length} {m["suggest_pmSteps"]()}</span>
</div>
{#if day.pm_steps.length}

View file

@ -6,9 +6,9 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Sparkles, Pencil, X } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -17,11 +17,11 @@
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
const statePills: Record<string, string> = {
excellent: 'state-pill state-pill--excellent',
good: 'state-pill state-pill--good',
fair: 'state-pill state-pill--fair',
poor: 'state-pill state-pill--poor'
};
const stateLabels: Record<string, () => string> = {
@ -102,12 +102,17 @@
}
// AI photo analysis state
let aiPanelOpen = $state(false);
let aiModalOpen = $state(false);
let selectedFiles = $state<File[]>([]);
let previewUrls = $state<string[]>([]);
let aiLoading = $state(false);
let aiError = $state('');
const overallStateOptions = $derived(states.map((s) => ({ value: s, label: stateLabels[s]?.() ?? s })));
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
);
@ -137,180 +142,219 @@
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes;
aiPanelOpen = false;
aiModalOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
function openAiModal() {
aiError = '';
aiModalOpen = true;
}
function closeAiModal() {
if (aiLoading) return;
aiModalOpen = false;
}
function handleModalKeydown(event: KeyboardEvent) {
if (!aiModalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
closeAiModal();
}
}
</script>
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
<svelte:window onkeydown={handleModalKeydown} />
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.skin_title()}</h2>
<p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p>
<div class="editorial-toolbar">
{#if showForm}
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
<Sparkles class="size-4" />
{m["skin_aiAnalysisTitle"]()}
</Button>
{/if}
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["skin_addNew"]()}
</Button>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["skin_addNew"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotAdded"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotAdded"]()}</div>
{/if}
{#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotUpdated"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotUpdated"]()}</div>
{/if}
{#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotDeleted"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotDeleted"]()}</div>
{/if}
{#if showForm}
<!-- AI photo analysis card -->
<Card>
<CardHeader>
<button
type="button"
class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}
>
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
</button>
</CardHeader>
{#if aiPanelOpen}
<CardContent class="space-y-3">
<p class="text-sm text-muted-foreground">
{m["skin_aiUploadText"]()}
</p>
<input
type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground
file:mr-4 file:rounded-md file:border-0 file:bg-primary
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
{/each}
{#if aiModalOpen}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
onclick={closeAiModal}
aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
<X class="size-4" />
</Button>
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button
type="button"
onclick={analyzePhotos}
disabled={aiLoading || !selectedFiles.length}
>
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
</Button>
</CardContent>
{/if}
</Card>
</CardHeader>
<CardContent class="space-y-3 overflow-y-auto p-4">
<p class="text-sm text-muted-foreground">{m["skin_aiUploadText"]()}</p>
<input
type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
{/each}
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
</Button>
</div>
</CardContent>
</Card>
</div>
{/if}
<!-- New snapshot form -->
<Card>
<Card class="reveal-2">
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="snapshot_date">{m.skin_date()}</Label>
<Input
id="snapshot_date"
name="snapshot_date"
type="date"
bind:value={snapshotDate}
required
/>
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={overallState} />
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={skinType} />
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={barrierState} />
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="hydration_level">{m.skin_hydration()}</Label>
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
</div>
<div class="space-y-1">
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
</div>
<div class="space-y-1">
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="priorities">{m["skin_priorities"]()}</Label>
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">{m.skin_notes()}</Label>
<Input id="notes" name="notes" bind:value={notes} />
</div>
<LabeledInputField
id="snapshot_date"
name="snapshot_date"
label={m.skin_date()}
type="date"
required
bind:value={snapshotDate}
/>
<SimpleSelect
id="overall_state"
name="overall_state"
label={m["skin_overallState"]()}
options={overallStateOptions}
placeholder={m.common_select()}
bind:value={overallState}
/>
<SimpleSelect
id="texture"
name="texture"
label={m.skin_texture()}
options={textureOptions}
placeholder={m.common_select()}
bind:value={texture}
/>
<SimpleSelect
id="skin_type"
name="skin_type"
label={m["skin_skinType"]()}
options={skinTypeOptions}
placeholder={m.common_select()}
bind:value={skinType}
/>
<SimpleSelect
id="barrier_state"
name="barrier_state"
label={m["skin_barrierState"]()}
options={barrierOptions}
placeholder={m.common_select()}
bind:value={barrierState}
/>
<LabeledInputField
id="hydration_level"
name="hydration_level"
label={m.skin_hydration()}
type="number"
min="1"
max="5"
bind:value={hydrationLevel}
/>
<LabeledInputField
id="sensitivity_level"
name="sensitivity_level"
label={m.skin_sensitivity()}
type="number"
min="1"
max="5"
bind:value={sensitivityLevel}
/>
<LabeledInputField
id="sebum_tzone"
name="sebum_tzone"
label={m["skin_sebumTzone"]()}
type="number"
min="1"
max="5"
bind:value={sebumTzone}
/>
<LabeledInputField
id="sebum_cheeks"
name="sebum_cheeks"
label={m["skin_sebumCheeks"]()}
type="number"
min="1"
max="5"
bind:value={sebumCheeks}
/>
<LabeledInputField
id="active_concerns"
name="active_concerns"
label={m["skin_activeConcerns"]()}
placeholder={m["skin_activeConcernsPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={activeConcernsRaw}
/>
<LabeledInputField
id="priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={prioritiesRaw}
/>
<LabeledInputField
id="notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={notes}
/>
<div class="col-span-2">
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
</div>
@ -319,7 +363,7 @@
</Card>
{/if}
<div class="space-y-4">
<div class="space-y-4 reveal-2">
{#each sortedSnapshots as snap (snap.id)}
<Card>
<CardContent class="pt-4">
@ -335,86 +379,105 @@
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<input type="hidden" name="id" value={snap.id} />
<div class="space-y-1">
<Label for="edit_snapshot_date">{m.skin_date()}</Label>
<Input id="edit_snapshot_date" name="snapshot_date" type="date" bind:value={editSnapshotDate} required />
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={editOverallState} />
<Select type="single" value={editOverallState} onValueChange={(v) => (editOverallState = v)}>
<SelectTrigger>{editOverallState ? stateLabels[editOverallState]?.() ?? editOverallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={editTexture} />
<Select type="single" value={editTexture} onValueChange={(v) => (editTexture = v)}>
<SelectTrigger>{editTexture ? textureLabels[editTexture]?.() ?? editTexture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={editSkinType} />
<Select type="single" value={editSkinType} onValueChange={(v) => (editSkinType = v)}>
<SelectTrigger>{editSkinType ? skinTypeLabels[editSkinType]?.() ?? editSkinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={editBarrierState} />
<Select type="single" value={editBarrierState} onValueChange={(v) => (editBarrierState = v)}>
<SelectTrigger>{editBarrierState ? barrierLabels[editBarrierState]?.() ?? editBarrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="edit_hydration_level">{m.skin_hydration()}</Label>
<Input id="edit_hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={editHydrationLevel} />
</div>
<div class="space-y-1">
<Label for="edit_sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="edit_sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={editSensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="edit_sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="edit_sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={editSebumTzone} />
</div>
<div class="space-y-1">
<Label for="edit_sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="edit_sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={editSebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_notes">{m.skin_notes()}</Label>
<Input id="edit_notes" name="notes" bind:value={editNotes} />
</div>
<LabeledInputField
id="edit_snapshot_date"
name="snapshot_date"
label={m.skin_date()}
type="date"
required
bind:value={editSnapshotDate}
/>
<SimpleSelect
id="edit_overall_state"
name="overall_state"
label={m["skin_overallState"]()}
options={overallStateOptions}
placeholder={m.common_select()}
bind:value={editOverallState}
/>
<SimpleSelect
id="edit_texture"
name="texture"
label={m.skin_texture()}
options={textureOptions}
placeholder={m.common_select()}
bind:value={editTexture}
/>
<SimpleSelect
id="edit_skin_type"
name="skin_type"
label={m["skin_skinType"]()}
options={skinTypeOptions}
placeholder={m.common_select()}
bind:value={editSkinType}
/>
<SimpleSelect
id="edit_barrier_state"
name="barrier_state"
label={m["skin_barrierState"]()}
options={barrierOptions}
placeholder={m.common_select()}
bind:value={editBarrierState}
/>
<LabeledInputField
id="edit_hydration_level"
name="hydration_level"
label={m.skin_hydration()}
type="number"
min="1"
max="5"
bind:value={editHydrationLevel}
/>
<LabeledInputField
id="edit_sensitivity_level"
name="sensitivity_level"
label={m.skin_sensitivity()}
type="number"
min="1"
max="5"
bind:value={editSensitivityLevel}
/>
<LabeledInputField
id="edit_sebum_tzone"
name="sebum_tzone"
label={m["skin_sebumTzone"]()}
type="number"
min="1"
max="5"
bind:value={editSebumTzone}
/>
<LabeledInputField
id="edit_sebum_cheeks"
name="sebum_cheeks"
label={m["skin_sebumCheeks"]()}
type="number"
min="1"
max="5"
bind:value={editSebumCheeks}
/>
<LabeledInputField
id="edit_active_concerns"
name="active_concerns"
label={m["skin_activeConcerns"]()}
placeholder={m["skin_activeConcernsPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={editActiveConcernsRaw}
/>
<LabeledInputField
id="edit_priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={editPrioritiesRaw}
/>
<LabeledInputField
id="edit_notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={editNotes}
/>
<div class="col-span-2 flex gap-2">
<Button type="submit">{m.common_save()}</Button>
<Button type="button" variant="outline" onclick={() => (editingId = null)}>{m.common_cancel()}</Button>
@ -426,19 +489,19 @@
<div class="flex items-center justify-between">
<span class="font-medium">{snap.snapshot_date}</span>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}><Pencil class="size-4" /></Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}><X class="size-4" /></Button>
</form>
</div>
</div>
{#if snap.overall_state || snap.texture}
<div class="flex flex-wrap items-center gap-1.5">
{#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span>
{#if snap.overall_state}
<span class={statePills[snap.overall_state] ?? 'state-pill'}>
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span>
{/if}
{#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>

View file

@ -1,10 +1,23 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import type { Plugin, Rollup } from 'vite';
import { defineConfig } from 'vite';
import { paraglideVitePlugin } from '@inlang/paraglide-js';
const stripDeprecatedRollupOptions: Plugin = {
name: 'strip-deprecated-rollup-options',
outputOptions(options: Rollup.OutputOptions) {
const nextOptions = { ...options } as Rollup.OutputOptions & { codeSplitting?: unknown };
if ('codeSplitting' in nextOptions) {
delete nextOptions.codeSplitting;
}
return nextOptions;
}
};
export default defineConfig({
plugins: [
stripDeprecatedRollupOptions,
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }),
tailwindcss(),
sveltekit()