Compare commits

..

No commits in common. "c869f88db209e8df746e161917c89fad70c1c58f" and "1d8a8eafb8f03982501cc3759538c7359eb99b22" have entirely different histories.

56 changed files with 1802 additions and 4026 deletions

View file

@ -1,45 +0,0 @@
"""replace_price_tier_with_objective_price_fields
Revision ID: 7c91e4b2af38
Revises: e4f5a6b7c8d9
Create Date: 2026-03-04 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "7c91e4b2af38"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("products", sa.Column("price_amount", sa.Float(), nullable=True))
op.add_column(
"products", sa.Column("price_currency", sa.String(length=3), nullable=True)
)
op.drop_index(op.f("ix_products_price_tier"), table_name="products")
op.drop_column("products", "price_tier")
op.execute("DROP TYPE IF EXISTS pricetier")
def downgrade() -> None:
op.execute("CREATE TYPE pricetier AS ENUM ('BUDGET', 'MID', 'PREMIUM', 'LUXURY')")
op.add_column(
"products",
sa.Column(
"price_tier",
sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"),
nullable=True,
),
)
op.create_index(
op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False
)
op.drop_column("products", "price_currency")
op.drop_column("products", "price_amount")

View file

@ -1,28 +0,0 @@
"""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 import json
from datetime import date from datetime import date
from typing import Literal, Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -17,7 +17,6 @@ from innercontext.llm import (
get_creative_config, get_creative_config,
get_extraction_config, get_extraction_config,
) )
from innercontext.services.fx import convert_to_pln
from innercontext.models import ( from innercontext.models import (
Product, Product,
ProductBase, ProductBase,
@ -39,6 +38,7 @@ from innercontext.models.product import (
ActiveIngredient, ActiveIngredient,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
) )
router = APIRouter() router = APIRouter()
@ -68,11 +68,8 @@ class ProductUpdate(SQLModel):
absorption_speed: Optional[AbsorptionSpeed] = None absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None leave_on: Optional[bool] = None
price_amount: Optional[float] = None price_tier: Optional[PriceTier] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None size_ml: Optional[float] = None
full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None
pao_months: Optional[int] = None pao_months: Optional[int] = None
inci: Optional[list[str]] = None inci: Optional[list[str]] = None
@ -94,6 +91,8 @@ class ProductUpdate(SQLModel):
ph_min: Optional[float] = None ph_min: Optional[float] = None
ph_max: 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 context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None min_interval_hours: Optional[int] = None
@ -123,8 +122,7 @@ class ProductParseResponse(SQLModel):
texture: Optional[TextureType] = None texture: Optional[TextureType] = None
absorption_speed: Optional[AbsorptionSpeed] = None absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None leave_on: Optional[bool] = None
price_amount: Optional[float] = None price_tier: Optional[PriceTier] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None size_ml: Optional[float] = None
full_weight_g: Optional[float] = None full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None empty_weight_g: Optional[float] = None
@ -142,6 +140,8 @@ class ProductParseResponse(SQLModel):
product_effect_profile: Optional[ProductEffectProfile] = None product_effect_profile: Optional[ProductEffectProfile] = None
ph_min: Optional[float] = None ph_min: Optional[float] = None
ph_max: 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 context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None max_frequency_per_week: Optional[int] = None
@ -218,188 +218,6 @@ class _ShoppingSuggestionsOut(PydanticBase):
reasoning: str 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 # Product routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -454,12 +272,8 @@ def list_products(
inv_by_product.setdefault(inv.product_id, []).append(inv) inv_by_product.setdefault(inv.product_id, []).append(inv)
results = [] results = []
pricing_pool = list(session.exec(select(Product)).all()) if products else []
pricing_outputs = _compute_pricing_outputs(pricing_pool)
for p in products: for p in products:
r = ProductWithInventory.model_validate(p, from_attributes=True) 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, []) r.inventory = inv_by_product.get(p.id, [])
results.append(r) results.append(r)
return results return results
@ -467,13 +281,9 @@ def list_products(
@router.post("", response_model=ProductPublic, status_code=201) @router.post("", response_model=ProductPublic, status_code=201)
def create_product(data: ProductCreate, session: Session = Depends(get_session)): 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( product = Product(
id=uuid4(), id=uuid4(),
**payload, **data.model_dump(),
) )
session.add(product) session.add(product)
session.commit() session.commit()
@ -519,6 +329,8 @@ texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fl
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow" absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
price_tier: "budget" | "mid" | "premium" | "luxury"
recommended_for (array, pick applicable): recommended_for (array, pick applicable):
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone" "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
@ -535,6 +347,8 @@ actives[].functions (array, pick applicable):
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high) actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
actives[].irritation_potential: 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): OUTPUT SCHEMA (all fields optional omit what you cannot determine):
{ {
"name": string, "name": string,
@ -548,8 +362,7 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
"texture": string, "texture": string,
"absorption_speed": string, "absorption_speed": string,
"leave_on": boolean, "leave_on": boolean,
"price_amount": number, "price_tier": string,
"price_currency": string,
"size_ml": number, "size_ml": number,
"full_weight_g": number, "full_weight_g": number,
"empty_weight_g": number, "empty_weight_g": number,
@ -589,6 +402,10 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
}, },
"ph_min": number, "ph_min": number,
"ph_max": number, "ph_max": number,
"incompatible_with": [
{"target": string, "scope": string, "reason": string}
],
"synergizes_with": [string, ...],
"context_rules": { "context_rules": {
"safe_after_shaving": boolean, "safe_after_shaving": boolean,
"safe_after_acids": boolean, "safe_after_acids": boolean,
@ -634,14 +451,10 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
@router.get("/{product_id}", response_model=ProductWithInventory) @router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(product_id: UUID, session: Session = Depends(get_session)): def get_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id) 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( inventory = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product_id) select(ProductInventory).where(ProductInventory.product_id == product_id)
).all() ).all()
result = ProductWithInventory.model_validate(product, from_attributes=True) result = ProductWithInventory.model_validate(product, from_attributes=True)
_with_pricing(result, pricing_outputs.get(product.id, (None, None, None)))
result.inventory = list(inventory) result.inventory = list(inventory)
return result return result
@ -651,19 +464,12 @@ def update_product(
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
): ):
product = get_or_404(session, Product, product_id) product = get_or_404(session, Product, product_id)
patch_data = data.model_dump(exclude_unset=True) for key, value in data.model_dump(exclude_unset=True).items():
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) setattr(product, key, value)
session.add(product) session.add(product)
session.commit() session.commit()
session.refresh(product) session.refresh(product)
pricing_pool = list(session.exec(select(Product)).all()) return product
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) @router.delete("/{product_id}", status_code=204)
@ -915,6 +721,7 @@ def _build_safety_rules_tool_handler(products: list[Product]):
return { return {
"id": pid, "id": pid,
"name": product.name, "name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {}, "context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {}, "safety": ctx.get("safety") or {},
@ -948,7 +755,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules", name="get_product_safety_rules",
description=( description=(
"Return safety and compatibility rules for selected product UUIDs, " "Return safety and compatibility rules for selected product UUIDs, "
"including contraindications, context_rules and safety flags." "including incompatible_with, contraindications, context_rules and safety flags."
), ),
parameters=genai_types.Schema( parameters=genai_types.Schema(
type=genai_types.Type.OBJECT, type=genai_types.Type.OBJECT,

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ from .enums import (
AbsorptionSpeed, AbsorptionSpeed,
DayTime, DayTime,
IngredientFunction, IngredientFunction,
InteractionScope,
PriceTier, PriceTier,
ProductCategory, ProductCategory,
SkinConcern, SkinConcern,
@ -56,6 +57,12 @@ class ActiveIngredient(SQLModel):
irritation_potential: StrengthLevel | None = None irritation_potential: StrengthLevel | None = None
class ProductInteraction(SQLModel):
target: str
scope: InteractionScope
reason: str | None = None
class ProductContext(SQLModel): class ProductContext(SQLModel):
safe_after_shaving: bool | None = None safe_after_shaving: bool | None = None
safe_after_acids: bool | None = None safe_after_acids: bool | None = None
@ -94,8 +101,7 @@ class ProductBase(SQLModel):
absorption_speed: AbsorptionSpeed | None = None absorption_speed: AbsorptionSpeed | None = None
leave_on: bool leave_on: bool
price_amount: float | None = Field(default=None, gt=0) price_tier: PriceTier | None = None
price_currency: str | None = Field(default=None, min_length=3, max_length=3)
size_ml: float | None = Field(default=None, gt=0) size_ml: float | None = Field(default=None, gt=0)
full_weight_g: 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) empty_weight_g: float | None = Field(default=None, gt=0)
@ -122,6 +128,8 @@ class ProductBase(SQLModel):
ph_min: float | None = Field(default=None, ge=0, le=14) ph_min: float | None = Field(default=None, ge=0, le=14)
ph_max: 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 context_rules: ProductContext | None = None
min_interval_hours: int | None = Field(default=None, ge=0) min_interval_hours: int | None = Field(default=None, ge=0)
@ -146,6 +154,9 @@ class Product(ProductBase, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=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) # Override 9 JSON fields with sa_column (only in table model)
inci: list[str] = Field( inci: list[str] = Field(
default_factory=list, sa_column=Column(JSON, nullable=False) default_factory=list, sa_column=Column(JSON, nullable=False)
@ -170,6 +181,12 @@ class Product(ProductBase, table=True):
sa_column=Column(JSON, nullable=False), 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( context_rules: ProductContext | None = Field(
default=None, sa_column=Column(JSON, nullable=True) default=None, sa_column=Column(JSON, nullable=True)
) )
@ -215,17 +232,9 @@ class Product(ProductBase, table=True):
if self.is_medication and not self.usage_notes: if self.is_medication and not self.usage_notes:
raise ValueError("Medication products must have 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 return self
def to_llm_context( def to_llm_context(self) -> dict:
self,
*,
computed_price_tier: PriceTier | None = None,
price_per_use_pln: float | None = None,
) -> dict:
ctx: dict = { ctx: dict = {
"id": str(self.id), "id": str(self.id),
"name": self.name, "name": self.name,
@ -244,14 +253,8 @@ class Product(ProductBase, table=True):
ctx["texture"] = _ev(self.texture) ctx["texture"] = _ev(self.texture)
if self.absorption_speed is not None: if self.absorption_speed is not None:
ctx["absorption_speed"] = _ev(self.absorption_speed) ctx["absorption_speed"] = _ev(self.absorption_speed)
if self.price_amount is not None: if self.price_tier is not None:
ctx["price_amount"] = self.price_amount ctx["price_tier"] = _ev(self.price_tier)
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: if self.size_ml is not None:
ctx["size_ml"] = self.size_ml ctx["size_ml"] = self.size_ml
if self.pao_months is not None: if self.pao_months is not None:
@ -299,6 +302,26 @@ class Product(ProductBase, table=True):
if nonzero: if nonzero:
ctx["effect_profile"] = 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: if self.context_rules is not None:
cr = self.context_rules cr = self.context_rules
if isinstance(cr, dict): if isinstance(cr, dict):
@ -382,9 +405,6 @@ class ProductPublic(ProductBase):
id: UUID id: UUID
created_at: datetime created_at: datetime
updated_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): class ProductWithInventory(ProductPublic):

View file

@ -1,77 +0,0 @@
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", "alembic>=1.14",
"fastapi>=0.132.0", "fastapi>=0.132.0",
"google-genai>=1.65.0", "google-genai>=1.65.0",
"psycopg[binary]>=3.3.3", "psycopg>=3.3.3",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"python-multipart>=0.0.22", "python-multipart>=0.0.22",
"sqlmodel>=0.0.37", "sqlmodel>=0.0.37",

View file

@ -7,12 +7,14 @@ from innercontext.models import Product
from innercontext.models.enums import ( from innercontext.models.enums import (
DayTime, DayTime,
IngredientFunction, IngredientFunction,
InteractionScope,
ProductCategory, ProductCategory,
) )
from innercontext.models.product import ( from innercontext.models.product import (
ActiveIngredient, ActiveIngredient,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
) )
@ -154,6 +156,29 @@ def test_effect_profile_nonzero_included():
assert "retinoid_strength" not in ctx["effect_profile"] 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 # Context rules
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

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

View file

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

49
backend/uv.lock generated
View file

@ -556,7 +556,7 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "google-genai" }, { name = "google-genai" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
@ -579,7 +579,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "fastapi", specifier = ">=0.132.0" }, { name = "fastapi", specifier = ">=0.132.0" },
{ name = "google-genai", specifier = ">=1.65.0" }, { name = "google-genai", specifier = ">=1.65.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "psycopg", specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-multipart", specifier = ">=0.0.22" }, { name = "python-multipart", specifier = ">=0.0.22" },
{ name = "sqlmodel", specifier = ">=0.0.37" }, { name = "sqlmodel", specifier = ">=0.0.37" },
@ -739,51 +739,6 @@ 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" }, { 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]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.2" version = "0.6.2"

View file

@ -1,189 +0,0 @@
# 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,12 +22,6 @@
"common_unknown_value": "Unknown", "common_unknown_value": "Unknown",
"common_optional_notes": "optional", "common_optional_notes": "optional",
"common_steps": "steps", "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_title": "Dashboard",
"dashboard_subtitle": "Your recent health & skincare overview", "dashboard_subtitle": "Your recent health & skincare overview",
@ -67,9 +61,8 @@
"products_colBrand": "Brand", "products_colBrand": "Brand",
"products_colTargets": "Targets", "products_colTargets": "Targets",
"products_colTime": "Time", "products_colTime": "Time",
"products_colPricePerUse": "PLN/use",
"products_newTitle": "New Product", "products_newTitle": "New Product",
"products_backToList": "Products", "products_backToList": "Products",
"products_createProduct": "Create product", "products_createProduct": "Create product",
"products_saveChanges": "Save changes", "products_saveChanges": "Save changes",
"products_deleteProduct": "Delete product", "products_deleteProduct": "Delete product",
@ -113,7 +106,7 @@
"routines_addNew": "+ New routine", "routines_addNew": "+ New routine",
"routines_noRoutines": "No routines found.", "routines_noRoutines": "No routines found.",
"routines_newTitle": "New Routine", "routines_newTitle": "New Routine",
"routines_backToList": "Routines", "routines_backToList": "Routines",
"routines_detailsTitle": "Routine details", "routines_detailsTitle": "Routine details",
"routines_date": "Date *", "routines_date": "Date *",
"routines_amOrPm": "AM or PM *", "routines_amOrPm": "AM or PM *",
@ -131,15 +124,12 @@
"routines_dosePlaceholder": "e.g. 2 pumps", "routines_dosePlaceholder": "e.g. 2 pumps",
"routines_region": "Region", "routines_region": "Region",
"routines_regionPlaceholder": "e.g. face", "routines_regionPlaceholder": "e.g. face",
"routines_action": "Action",
"routines_selectAction": "Select action",
"routines_actionNotesPlaceholder": "Optional notes",
"routines_addStepBtn": "Add step", "routines_addStepBtn": "Add step",
"routines_unknownStep": "Unknown step", "routines_unknownStep": "Unknown step",
"routines_noSteps": "No steps yet.", "routines_noSteps": "No steps yet.",
"grooming_title": "Grooming Schedule", "grooming_title": "Grooming Schedule",
"grooming_backToRoutines": "Routines", "grooming_backToRoutines": "Routines",
"grooming_addEntry": "+ Add entry", "grooming_addEntry": "+ Add entry",
"grooming_entryAdded": "Entry added.", "grooming_entryAdded": "Entry added.",
"grooming_entryUpdated": "Entry updated.", "grooming_entryUpdated": "Entry updated.",
@ -162,7 +152,7 @@
"grooming_daySunday": "Sunday", "grooming_daySunday": "Sunday",
"suggest_title": "AI Routine Suggestion", "suggest_title": "AI Routine Suggestion",
"suggest_backToRoutines": "Routines", "suggest_backToRoutines": "Routines",
"suggest_singleTab": "Single routine", "suggest_singleTab": "Single routine",
"suggest_batchTab": "Batch / Vacation", "suggest_batchTab": "Batch / Vacation",
"suggest_singleParams": "Parameters", "suggest_singleParams": "Parameters",
@ -175,8 +165,6 @@
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.", "suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)", "suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.", "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_generateBtn": "Generate suggestion",
"suggest_generating": "Generating…", "suggest_generating": "Generating…",
"suggest_proposalTitle": "Suggestion", "suggest_proposalTitle": "Suggestion",
@ -270,7 +258,6 @@
"labResults_flagNone": "None", "labResults_flagNone": "None",
"labResults_date": "Date *", "labResults_date": "Date *",
"labResults_loincCode": "LOINC code *", "labResults_loincCode": "LOINC code *",
"labResults_loincExample": "e.g. 718-7",
"labResults_testName": "Test name", "labResults_testName": "Test name",
"labResults_testNamePlaceholder": "e.g. Hemoglobin", "labResults_testNamePlaceholder": "e.g. Hemoglobin",
"labResults_lab": "Lab", "labResults_lab": "Lab",
@ -349,7 +336,7 @@
"productForm_aiPrefill": "AI pre-fill", "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_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_pasteText": "Paste product description, INCI ingredients here...",
"productForm_parseWithAI": "Fill", "productForm_parseWithAI": "Fill fields (AI)",
"productForm_parsing": "Processing…", "productForm_parsing": "Processing…",
"productForm_basicInfo": "Basic info", "productForm_basicInfo": "Basic info",
"productForm_name": "Name *", "productForm_name": "Name *",
@ -359,7 +346,6 @@
"productForm_lineName": "Line / series", "productForm_lineName": "Line / series",
"productForm_lineNamePlaceholder": "e.g. Hydro Boost", "productForm_lineNamePlaceholder": "e.g. Hydro Boost",
"productForm_url": "URL", "productForm_url": "URL",
"productForm_urlPlaceholder": "https://...",
"productForm_sku": "SKU", "productForm_sku": "SKU",
"productForm_skuPlaceholder": "e.g. NTR-HB-50", "productForm_skuPlaceholder": "e.g. NTR-HB-50",
"productForm_barcode": "Barcode / EAN", "productForm_barcode": "Barcode / EAN",
@ -384,18 +370,25 @@
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares", "productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
"productForm_ingredients": "Ingredients", "productForm_ingredients": "Ingredients",
"productForm_inciList": "INCI list (one ingredient per line)", "productForm_inciList": "INCI list (one ingredient per line)",
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
"productForm_activeIngredients": "Active ingredients", "productForm_activeIngredients": "Active ingredients",
"productForm_addActive": "+ Add active", "productForm_addActive": "+ Add active",
"productForm_noActives": "No actives added yet.", "productForm_noActives": "No actives added yet.",
"productForm_activeName": "Name", "productForm_activeName": "Name",
"productForm_activeNamePlaceholder": "e.g. Niacinamide",
"productForm_activePercent": "%", "productForm_activePercent": "%",
"productForm_activePercentPlaceholder": "e.g. 5",
"productForm_activeStrength": "Strength", "productForm_activeStrength": "Strength",
"productForm_activeIrritation": "Irritation", "productForm_activeIrritation": "Irritation",
"productForm_activeFunctions": "Functions", "productForm_activeFunctions": "Functions",
"productForm_effectProfile": "Effect profile (05)", "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_contextRules": "Context rules",
"productForm_ctxAfterShaving": "Safe after shaving", "productForm_ctxAfterShaving": "Safe after shaving",
"productForm_ctxAfterAcids": "Safe after acids", "productForm_ctxAfterAcids": "Safe after acids",
@ -404,19 +397,6 @@
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)", "productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
"productForm_productDetails": "Product details", "productForm_productDetails": "Product details",
"productForm_priceTier": "Price tier", "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_selectTier": "Select tier",
"productForm_sizeMl": "Size (ml)", "productForm_sizeMl": "Size (ml)",
"productForm_fullWeightG": "Full weight (g)", "productForm_fullWeightG": "Full weight (g)",
@ -515,6 +495,10 @@
"productForm_fnVitaminC": "vitamin C", "productForm_fnVitaminC": "vitamin C",
"productForm_fnAntiAging": "anti-aging", "productForm_fnAntiAging": "anti-aging",
"productForm_scopeSameStep": "same step",
"productForm_scopeSameDay": "same day",
"productForm_scopeSamePeriod": "same period",
"productForm_strengthLow": "1 Low", "productForm_strengthLow": "1 Low",
"productForm_strengthMedium": "2 Medium", "productForm_strengthMedium": "2 Medium",
"productForm_strengthHigh": "3 High", "productForm_strengthHigh": "3 High",

View file

@ -22,12 +22,6 @@
"common_unknown_value": "Nieznane", "common_unknown_value": "Nieznane",
"common_optional_notes": "opcjonalnie", "common_optional_notes": "opcjonalnie",
"common_steps": "kroków", "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_title": "Dashboard",
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji", "dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
@ -69,9 +63,8 @@
"products_colBrand": "Marka", "products_colBrand": "Marka",
"products_colTargets": "Cele", "products_colTargets": "Cele",
"products_colTime": "Pora", "products_colTime": "Pora",
"products_colPricePerUse": "PLN/use",
"products_newTitle": "Nowy produkt", "products_newTitle": "Nowy produkt",
"products_backToList": "Produkty", "products_backToList": "Produkty",
"products_createProduct": "Utwórz produkt", "products_createProduct": "Utwórz produkt",
"products_saveChanges": "Zapisz zmiany", "products_saveChanges": "Zapisz zmiany",
"products_deleteProduct": "Usuń produkt", "products_deleteProduct": "Usuń produkt",
@ -117,7 +110,7 @@
"routines_addNew": "+ Nowa rutyna", "routines_addNew": "+ Nowa rutyna",
"routines_noRoutines": "Nie znaleziono rutyn.", "routines_noRoutines": "Nie znaleziono rutyn.",
"routines_newTitle": "Nowa rutyna", "routines_newTitle": "Nowa rutyna",
"routines_backToList": "Rutyny", "routines_backToList": "Rutyny",
"routines_detailsTitle": "Szczegóły rutyny", "routines_detailsTitle": "Szczegóły rutyny",
"routines_date": "Data *", "routines_date": "Data *",
"routines_amOrPm": "AM lub PM *", "routines_amOrPm": "AM lub PM *",
@ -135,15 +128,12 @@
"routines_dosePlaceholder": "np. 2 pompki", "routines_dosePlaceholder": "np. 2 pompki",
"routines_region": "Okolica", "routines_region": "Okolica",
"routines_regionPlaceholder": "np. twarz", "routines_regionPlaceholder": "np. twarz",
"routines_action": "Czynność",
"routines_selectAction": "Wybierz czynność",
"routines_actionNotesPlaceholder": "Opcjonalne notatki",
"routines_addStepBtn": "Dodaj krok", "routines_addStepBtn": "Dodaj krok",
"routines_unknownStep": "Nieznany krok", "routines_unknownStep": "Nieznany krok",
"routines_noSteps": "Brak kroków.", "routines_noSteps": "Brak kroków.",
"grooming_title": "Harmonogram pielęgnacji", "grooming_title": "Harmonogram pielęgnacji",
"grooming_backToRoutines": "Rutyny", "grooming_backToRoutines": "Rutyny",
"grooming_addEntry": "+ Dodaj wpis", "grooming_addEntry": "+ Dodaj wpis",
"grooming_entryAdded": "Wpis dodany.", "grooming_entryAdded": "Wpis dodany.",
"grooming_entryUpdated": "Wpis zaktualizowany.", "grooming_entryUpdated": "Wpis zaktualizowany.",
@ -166,7 +156,7 @@
"grooming_daySunday": "Niedziela", "grooming_daySunday": "Niedziela",
"suggest_title": "Propozycja rutyny AI", "suggest_title": "Propozycja rutyny AI",
"suggest_backToRoutines": "Rutyny", "suggest_backToRoutines": "Rutyny",
"suggest_singleTab": "Jedna rutyna", "suggest_singleTab": "Jedna rutyna",
"suggest_batchTab": "Batch / Urlop", "suggest_batchTab": "Batch / Urlop",
"suggest_singleParams": "Parametry", "suggest_singleParams": "Parametry",
@ -179,8 +169,6 @@
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.", "suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)", "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_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_generateBtn": "Generuj propozycję",
"suggest_generating": "Generuję…", "suggest_generating": "Generuję…",
"suggest_proposalTitle": "Propozycja", "suggest_proposalTitle": "Propozycja",
@ -282,7 +270,6 @@
"labResults_flagNone": "Brak", "labResults_flagNone": "Brak",
"labResults_date": "Data *", "labResults_date": "Data *",
"labResults_loincCode": "Kod LOINC *", "labResults_loincCode": "Kod LOINC *",
"labResults_loincExample": "np. 718-7",
"labResults_testName": "Nazwa badania", "labResults_testName": "Nazwa badania",
"labResults_testNamePlaceholder": "np. Hemoglobina", "labResults_testNamePlaceholder": "np. Hemoglobina",
"labResults_lab": "Laboratorium", "labResults_lab": "Laboratorium",
@ -363,7 +350,7 @@
"productForm_aiPrefill": "Uzupełnienie AI", "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_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_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
"productForm_parseWithAI": "Uzupełnij", "productForm_parseWithAI": "Uzupełnij pola (AI)",
"productForm_parsing": "Przetwarzam…", "productForm_parsing": "Przetwarzam…",
"productForm_basicInfo": "Informacje podstawowe", "productForm_basicInfo": "Informacje podstawowe",
"productForm_name": "Nazwa *", "productForm_name": "Nazwa *",
@ -373,7 +360,6 @@
"productForm_lineName": "Linia / seria", "productForm_lineName": "Linia / seria",
"productForm_lineNamePlaceholder": "np. Hydro Boost", "productForm_lineNamePlaceholder": "np. Hydro Boost",
"productForm_url": "URL", "productForm_url": "URL",
"productForm_urlPlaceholder": "https://...",
"productForm_sku": "SKU", "productForm_sku": "SKU",
"productForm_skuPlaceholder": "np. NTR-HB-50", "productForm_skuPlaceholder": "np. NTR-HB-50",
"productForm_barcode": "Kod kreskowy / EAN", "productForm_barcode": "Kod kreskowy / EAN",
@ -398,18 +384,25 @@
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea", "productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
"productForm_ingredients": "Składniki", "productForm_ingredients": "Składniki",
"productForm_inciList": "Lista INCI (jeden składnik na linię)", "productForm_inciList": "Lista INCI (jeden składnik na linię)",
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
"productForm_activeIngredients": "Składniki aktywne", "productForm_activeIngredients": "Składniki aktywne",
"productForm_addActive": "+ Dodaj aktywny", "productForm_addActive": "+ Dodaj aktywny",
"productForm_noActives": "Brak składników aktywnych.", "productForm_noActives": "Brak składników aktywnych.",
"productForm_activeName": "Nazwa", "productForm_activeName": "Nazwa",
"productForm_activeNamePlaceholder": "np. Niacinamide",
"productForm_activePercent": "%", "productForm_activePercent": "%",
"productForm_activePercentPlaceholder": "np. 5",
"productForm_activeStrength": "Siła", "productForm_activeStrength": "Siła",
"productForm_activeIrritation": "Podrażnienie", "productForm_activeIrritation": "Podrażnienie",
"productForm_activeFunctions": "Funkcje", "productForm_activeFunctions": "Funkcje",
"productForm_effectProfile": "Profil działania (05)", "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_contextRules": "Reguły kontekstu",
"productForm_ctxAfterShaving": "Bezpieczny po goleniu", "productForm_ctxAfterShaving": "Bezpieczny po goleniu",
"productForm_ctxAfterAcids": "Bezpieczny po kwasach", "productForm_ctxAfterAcids": "Bezpieczny po kwasach",
@ -418,19 +411,6 @@
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)", "productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
"productForm_productDetails": "Szczegóły produktu", "productForm_productDetails": "Szczegóły produktu",
"productForm_priceTier": "Przedział cenowy", "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_selectTier": "Wybierz przedział",
"productForm_sizeMl": "Rozmiar (ml)", "productForm_sizeMl": "Rozmiar (ml)",
"productForm_fullWeightG": "Waga pełna (g)", "productForm_fullWeightG": "Waga pełna (g)",
@ -529,6 +509,10 @@
"productForm_fnVitaminC": "witamina C", "productForm_fnVitaminC": "witamina C",
"productForm_fnAntiAging": "przeciwstarzeniowy", "productForm_fnAntiAging": "przeciwstarzeniowy",
"productForm_scopeSameStep": "ten sam krok",
"productForm_scopeSameDay": "ten sam dzień",
"productForm_scopeSamePeriod": "ten sam okres",
"productForm_strengthLow": "1 Niskie", "productForm_strengthLow": "1 Niskie",
"productForm_strengthMedium": "2 Średnie", "productForm_strengthMedium": "2 Średnie",
"productForm_strengthHigh": "3 Wysokie", "productForm_strengthHigh": "3 Wysokie",

View file

@ -5,42 +5,26 @@
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */ /* ── CSS variable definitions (light / dark) ─────────────────────────────── */
:root { :root {
--background: hsl(42 35% 95%); --background: hsl(0 0% 100%);
--foreground: hsl(220 24% 14%); --foreground: hsl(240 10% 3.9%);
--card: hsl(44 32% 96%); --card: hsl(0 0% 100%);
--card-foreground: hsl(220 24% 14%); --card-foreground: hsl(240 10% 3.9%);
--popover: hsl(44 32% 96%); --popover: hsl(0 0% 100%);
--popover-foreground: hsl(220 24% 14%); --popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(15 44% 34%); --primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(42 40% 97%); --primary-foreground: hsl(0 0% 98%);
--secondary: hsl(38 24% 91%); --secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(220 20% 20%); --secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(42 20% 90%); --muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(219 12% 39%); --muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(42 24% 90%); --accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(220 24% 14%); --accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--border: hsl(35 23% 76%); --border: hsl(240 5.9% 90%);
--input: hsl(37 20% 80%); --input: hsl(240 5.9% 90%);
--ring: hsl(15 40% 38%); --ring: hsl(240 5.9% 10%);
--radius: 0.5rem; --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 { .dark {
@ -102,723 +86,4 @@
body { body {
background-color: var(--background); background-color: var(--background);
color: var(--foreground); 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,9 +3,6 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -11,6 +11,7 @@ import type {
Product, Product,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
ProductInventory, ProductInventory,
Routine, Routine,
RoutineSuggestion, RoutineSuggestion,
@ -108,8 +109,7 @@ export interface ProductParseResponse {
texture?: string; texture?: string;
absorption_speed?: string; absorption_speed?: string;
leave_on?: boolean; leave_on?: boolean;
price_amount?: number; price_tier?: string;
price_currency?: string;
size_ml?: number; size_ml?: number;
full_weight_g?: number; full_weight_g?: number;
empty_weight_g?: number; empty_weight_g?: number;
@ -127,6 +127,8 @@ export interface ProductParseResponse {
product_effect_profile?: ProductEffectProfile; product_effect_profile?: ProductEffectProfile;
ph_min?: number; ph_min?: number;
ph_max?: number; ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext; context_rules?: ProductContext;
min_interval_hours?: number; min_interval_hours?: number;
max_frequency_per_week?: number; max_frequency_per_week?: number;

View file

@ -1,34 +1,16 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type { Product } from '$lib/types'; import type { Product } from '$lib/types';
import type { IngredientFunction } from '$lib/types'; import type { IngredientFunction, InteractionScope } from '$lib/types';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes'; import { parseProductText, type ProductParseResponse } from '$lib/api';
import type { ProductParseResponse } from '$lib/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Sparkles, X } from 'lucide-svelte';
let { let { product }: { product?: Product } = $props();
product,
saveVersion = 0,
showAiTrigger = true,
onDirtyChange,
computedPriceLabel,
computedPricePerUseLabel,
computedPriceTierLabel
}: {
product?: Product;
saveVersion?: number;
showAiTrigger?: boolean;
onDirtyChange?: (dirty: boolean) => void;
computedPriceLabel?: string;
computedPricePerUseLabel?: string;
computedPriceTierLabel?: string;
} = $props();
// ── Enum option lists ───────────────────────────────────────────────────── // ── Enum option lists ─────────────────────────────────────────────────────
@ -38,6 +20,7 @@
]; ];
const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid']; const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid'];
const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow']; const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow'];
const priceTiers = ['budget', 'mid', 'premium', 'luxury'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone']; const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const skinConcerns = [ const skinConcerns = [
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration', 'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
@ -51,6 +34,7 @@
'brightening', 'anti_acne', 'ceramide', 'niacinamide', 'brightening', 'anti_acne', 'ceramide', 'niacinamide',
'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c', 'anti_aging' 'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c', 'anti_aging'
]; ];
const interactionScopes: InteractionScope[] = ['same_step', 'same_day', 'same_period'];
// ── Translated label maps ───────────────────────────────────────────────── // ── Translated label maps ─────────────────────────────────────────────────
@ -88,6 +72,13 @@
very_slow: m["productForm_absorptionVerySlow"]() very_slow: m["productForm_absorptionVerySlow"]()
}); });
const priceTierLabels = $derived<Record<string, string>>({
budget: m["productForm_priceBudget"](),
mid: m["productForm_priceMid"](),
premium: m["productForm_pricePremium"](),
luxury: m["productForm_priceLuxury"]()
});
const skinTypeLabels = $derived<Record<string, string>>({ const skinTypeLabels = $derived<Record<string, string>>({
dry: m["productForm_skinTypeDry"](), dry: m["productForm_skinTypeDry"](),
oily: m["productForm_skinTypeOily"](), oily: m["productForm_skinTypeOily"](),
@ -134,12 +125,22 @@
anti_aging: m["productForm_fnAntiAging"]() anti_aging: m["productForm_fnAntiAging"]()
}); });
const scopeLabels = $derived<Record<string, string>>({
same_step: m["productForm_scopeSameStep"](),
same_day: m["productForm_scopeSameDay"](),
same_period: m["productForm_scopeSamePeriod"]()
});
const tristate = $derived([ const tristate = $derived([
{ value: '', label: m.common_unknown() }, { value: '', label: m.common_unknown() },
{ value: 'true', label: m.common_yes() }, { value: 'true', label: m.common_yes() },
{ value: 'false', label: m.common_no() } { value: 'false', label: m.common_no() }
]); ]);
function tristateLabel(val: string): string {
return val === '' ? m.common_unknown() : val === 'true' ? m.common_yes() : m.common_no();
}
const effectFields = $derived([ const effectFields = $derived([
{ key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() }, { key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() },
{ key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() }, { key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() },
@ -176,7 +177,7 @@
let usageNotes = $state(untrack(() => product?.usage_notes ?? '')); let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? '')); let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? '')); let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
let personalToleranceNotes = $state(untrack(() => product?.personal_tolerance_notes ?? '')); let synergizesWithText = $state(untrack(() => product?.synergizes_with?.join('\n') ?? ''));
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])])); let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])])); let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
@ -185,22 +186,19 @@
// ── AI pre-fill state ───────────────────────────────────────────────────── // ── AI pre-fill state ─────────────────────────────────────────────────────
let aiModalOpen = $state(false); let aiPanelOpen = $state(false);
let aiText = $state(''); let aiText = $state('');
let aiLoading = $state(false); let aiLoading = $state(false);
let aiError = $state(''); let aiError = $state('');
let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic');
let activesPanelOpen = $state(true);
async function parseWithAi() { async function parseWithAi() {
if (!aiText.trim()) return; if (!aiText.trim()) return;
aiLoading = true; aiLoading = true;
aiError = ''; aiError = '';
try { try {
const { parseProductText } = await import('$lib/api');
const r = await parseProductText(aiText); const r = await parseProductText(aiText);
applyAiResult(r); applyAiResult(r);
aiModalOpen = false; aiPanelOpen = false;
} catch (e) { } catch (e) {
aiError = (e as Error).message; aiError = (e as Error).message;
} finally { } finally {
@ -220,8 +218,7 @@
if (r.recommended_time) recommendedTime = r.recommended_time; if (r.recommended_time) recommendedTime = r.recommended_time;
if (r.texture) texture = r.texture; if (r.texture) texture = r.texture;
if (r.absorption_speed) absorptionSpeed = r.absorption_speed; if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
if (r.price_amount != null) priceAmount = String(r.price_amount); if (r.price_tier) priceTier = r.price_tier;
if (r.price_currency) priceCurrency = r.price_currency;
if (r.leave_on != null) leaveOn = String(r.leave_on); if (r.leave_on != null) leaveOn = String(r.leave_on);
if (r.size_ml != null) sizeMl = String(r.size_ml); if (r.size_ml != null) sizeMl = String(r.size_ml);
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g); if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
@ -242,6 +239,7 @@
if (r.is_tool != null) isTool = r.is_tool; if (r.is_tool != null) isTool = r.is_tool;
if (r.inci?.length) inciText = r.inci.join('\n'); if (r.inci?.length) inciText = r.inci.join('\n');
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n'); if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
if (r.synergizes_with?.length) synergizesWithText = r.synergizes_with.join('\n');
if (r.actives?.length) { if (r.actives?.length) {
actives = r.actives.map((a) => ({ actives = r.actives.map((a) => ({
name: a.name, name: a.name,
@ -251,6 +249,13 @@
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : '' irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
})); }));
} }
if (r.incompatible_with?.length) {
incompatibleWith = r.incompatible_with.map((i) => ({
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
}));
}
if (r.product_effect_profile) { if (r.product_effect_profile) {
effectValues = { ...effectValues, ...r.product_effect_profile }; effectValues = { ...effectValues, ...r.product_effect_profile };
} }
@ -271,8 +276,7 @@
let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true'))); let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true')));
let texture = $state(untrack(() => product?.texture ?? '')); let texture = $state(untrack(() => product?.texture ?? ''));
let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? '')); let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? ''));
let priceAmount = $state(untrack(() => (product?.price_amount != null ? String(product.price_amount) : ''))); let priceTier = $state(untrack(() => product?.price_tier ?? ''));
let priceCurrency = $state(untrack(() => product?.price_currency ?? 'PLN'));
let fragranceFree = $state( let fragranceFree = $state(
untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : '')) untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : ''))
); );
@ -376,175 +380,192 @@
) )
); );
const textareaClass = `${baseTextareaClass} focus-visible:ring-offset-2`; // ── Dynamic incompatible_with ─────────────────────────────────────────────
const selectClass = baseSelectClass; type IncompatibleRow = { target: string; scope: string; reason: string };
export function openAiModal() { let incompatibleWith: IncompatibleRow[] = $state(
aiError = ''; untrack(() =>
aiModalOpen = true; product?.incompatible_with?.map((i) => ({
} target: i.target,
scope: i.scope,
function closeAiModal() { reason: i.reason ?? ''
if (aiLoading) return; })) ?? []
aiModalOpen = false; )
}
function handleModalKeydown(event: KeyboardEvent) {
if (!aiModalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
closeAiModal();
}
}
const formFingerprint = $derived(
JSON.stringify({
name,
brand,
lineName,
url,
sku,
barcode,
sizeMl,
fullWeightG,
emptyWeightG,
paoMonths,
phMin,
phMax,
minIntervalHours,
maxFrequencyPerWeek,
needleLengthMm,
usageNotes,
inciText,
contraindicationsText,
personalToleranceNotes,
recommendedFor,
targetConcerns,
isMedication,
isTool,
category,
recommendedTime,
leaveOn,
texture,
absorptionSpeed,
priceAmount,
priceCurrency,
fragranceFree,
essentialOilsFree,
alcoholDenatFree,
pregnancySafe,
personalRepurchaseIntent,
ctxAfterShaving,
ctxAfterAcids,
ctxAfterRetinoids,
ctxCompromisedBarrier,
ctxLowUvOnly,
effectValues,
actives: activesJson
})
); );
let baselineFingerprint = $state(''); function addIncompatible() {
let baselineProductId = $state(''); incompatibleWith = [...incompatibleWith, { target: '', scope: '', reason: '' }];
let baselineSaveVersion = $state(-1);
$effect(() => {
const currentProductId = product?.id ?? '';
const currentFingerprint = formFingerprint;
if (
!baselineFingerprint ||
currentProductId !== baselineProductId ||
saveVersion !== baselineSaveVersion
) {
baselineFingerprint = currentFingerprint;
baselineProductId = currentProductId;
baselineSaveVersion = saveVersion;
onDirtyChange?.(false);
return;
} }
onDirtyChange?.(currentFingerprint !== baselineFingerprint); function removeIncompatible(i: number) {
}); incompatibleWith = incompatibleWith.filter((_, idx) => idx !== i);
}
let incompatibleJson = $derived(
JSON.stringify(
incompatibleWith
.filter((i) => i.target.trim() && i.scope)
.map((i) => ({
target: i.target.trim(),
scope: i.scope,
...(i.reason.trim() ? { reason: i.reason.trim() } : {})
}))
)
);
const textareaClass =
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
const selectClass =
'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';
</script> </script>
<svelte:window onkeydown={handleModalKeydown} /> <!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
<Card>
<Tabs bind:value={editSection} class="space-y-2"> <CardHeader>
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap"> <button type="button" class="flex w-full items-center justify-between text-left"
<TabsTrigger value="basic" class="shrink-0 px-3">{m["productForm_basicInfo"]()}</TabsTrigger> onclick={() => (aiPanelOpen = !aiPanelOpen)}>
<TabsTrigger value="ingredients" class="shrink-0 px-3">{m["productForm_ingredients"]()}</TabsTrigger> <CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
<TabsTrigger value="assessment" class="shrink-0 px-3">{m["productForm_effectProfile"]()}</TabsTrigger> <span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
<TabsTrigger value="details" class="shrink-0 px-3">{m["productForm_productDetails"]()}</TabsTrigger> </button>
<TabsTrigger value="notes" class="shrink-0 px-3">{m["productForm_personalNotes"]()}</TabsTrigger> </CardHeader>
</TabsList> {#if aiPanelOpen}
</Tabs> <CardContent class="space-y-3">
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
{#if showAiTrigger} <textarea bind:value={aiText} rows="6"
<div class="flex justify-end"> placeholder={m["productForm_pasteText"]()}
<Button type="button" variant="outline" size="sm" onclick={openAiModal}> class={textareaClass}></textarea>
<Sparkles class="size-4" /> {#if aiError}
{m["productForm_aiPrefill"]()} <p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button type="button" onclick={parseWithAi}
disabled={aiLoading || !aiText.trim()}>
{aiLoading ? m["productForm_parsing"]() : m["productForm_parseWithAI"]()}
</Button> </Button>
</CardContent>
{/if}
</Card>
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
<Card>
<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>
{/if} <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="https://…" 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>
{#if aiModalOpen} <!-- ── Classification ────────────────────────────────────────────────────── -->
{#await import('$lib/components/ProductFormAiModal.svelte') then mod} <Card>
{@const AiModal = mod.default} <CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<AiModal <CardContent>
open={aiModalOpen} <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
bind:aiText <div class="col-span-2 space-y-2">
{aiLoading} <Label>{m["productForm_category"]()}</Label>
aiError={aiError} <input type="hidden" name="category" value={category} />
{textareaClass} <Select type="single" value={category} onValueChange={(v) => (category = v)}>
onClose={closeAiModal} <SelectTrigger>{category ? categoryLabels[category] : m["productForm_selectCategory"]()}</SelectTrigger>
onSubmit={parseWithAi} <SelectContent>
/> {#each categories as cat}
{/await} <SelectItem value={cat}>{categoryLabels[cat]}</SelectItem>
{/if} {/each}
</SelectContent>
</Select>
</div>
{#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod} <div class="space-y-2">
{@const BasicSection = mod.default} <Label>{m["productForm_time"]()}</Label>
<BasicSection <input type="hidden" name="recommended_time" value={recommendedTime} />
visible={editSection === 'basic'} <Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
bind:name <SelectTrigger>
bind:brand {recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
bind:lineName </SelectTrigger>
bind:url <SelectContent>
bind:sku <SelectItem value="am">AM</SelectItem>
bind:barcode <SelectItem value="pm">PM</SelectItem>
/> <SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
{/await} </SelectContent>
</Select>
</div>
{#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod} <div class="space-y-2">
{@const ClassificationSection = mod.default} <Label>{m["productForm_leaveOn"]()}</Label>
<ClassificationSection <input type="hidden" name="leave_on" value={leaveOn} />
visible={editSection === 'basic'} <Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
{selectClass} <SelectTrigger>{leaveOn === 'true' ? m["productForm_leaveOnYes"]() : m["productForm_leaveOnNo"]()}</SelectTrigger>
{categories} <SelectContent>
{textures} <SelectItem value="true">{m["productForm_leaveOnYes"]()}</SelectItem>
{absorptionSpeeds} <SelectItem value="false">{m["productForm_leaveOnNo"]()}</SelectItem>
{categoryLabels} </SelectContent>
{textureLabels} </Select>
{absorptionLabels} </div>
bind:category
bind:recommendedTime <div class="space-y-2">
bind:leaveOn <Label>{m["productForm_texture"]()}</Label>
bind:texture <input type="hidden" name="texture" value={texture} />
bind:absorptionSpeed <Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
/> <SelectTrigger>{texture ? textureLabels[texture] : m["productForm_selectTexture"]()}</SelectTrigger>
{/await} <SelectContent>
{#each textures as t}
<SelectItem value={t}>{textureLabels[t]}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_absorptionSpeed"]()}</Label>
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
<SelectTrigger>{absorptionSpeed ? absorptionLabels[absorptionSpeed] : m["productForm_selectSpeed"]()}</SelectTrigger>
<SelectContent>
{#each absorptionSpeeds as s}
<SelectItem value={s}>{absorptionLabels[s]}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<!-- ── Skin profile ───────────────────────────────────────────────────────── --> <!-- ── Skin profile ───────────────────────────────────────────────────────── -->
<Card class={editSection === 'ingredients' ? '' : 'hidden'}> <Card>
<CardHeader><CardTitle>{m["productForm_skinProfile"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_skinProfile"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_recommendedFor"]()}</Label> <Label>{m["productForm_recommendedFor"]()}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinTypes as st (st)} {#each skinTypes as st}
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@ -568,7 +589,7 @@
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_targetConcerns"]()}</Label> <Label>{m["productForm_targetConcerns"]()}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinConcerns as sc (sc)} {#each skinConcerns as sc}
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@ -604,7 +625,7 @@
</Card> </Card>
<!-- ── Ingredients ────────────────────────────────────────────────────────── --> <!-- ── Ingredients ────────────────────────────────────────────────────────── -->
<Card class={editSection === 'ingredients' ? '' : 'hidden'}> <Card>
<CardHeader><CardTitle>{m["productForm_ingredients"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_ingredients"]()}</CardTitle></CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
<div class="space-y-2"> <div class="space-y-2">
@ -613,7 +634,7 @@
id="inci" id="inci"
name="inci" name="inci"
rows="5" rows="5"
placeholder={m["productForm_inciPlaceholder"]()} placeholder="Aqua&#10;Glycerin&#10;Niacinamide"
class={textareaClass} class={textareaClass}
bind:value={inciText} bind:value={inciText}
></textarea> ></textarea>
@ -621,27 +642,19 @@
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<button <Label>{m["productForm_activeIngredients"]()}</Label>
type="button"
class="flex w-full items-center justify-between rounded-md border border-border px-3 py-2 text-left"
onclick={() => (activesPanelOpen = !activesPanelOpen)}
>
<span class="text-sm font-medium">{m["productForm_activeIngredients"]()}</span>
<span class="text-xs text-muted-foreground">{activesPanelOpen ? '' : '+'}</span>
</button>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button> <Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div> </div>
<input type="hidden" name="actives_json" value={activesJson} /> <input type="hidden" name="actives_json" value={activesJson} />
{#if activesPanelOpen} {#each actives as active, i}
{#each actives as active, i (i)}
<div class="rounded-md border border-border p-3 space-y-3"> <div class="rounded-md border border-border p-3 space-y-3">
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<Label class="text-xs">{m["productForm_activeName"]()}</Label> <Label class="text-xs">{m["productForm_activeName"]()}</Label>
<Input <Input
placeholder={m["productForm_activeNamePlaceholder"]()} placeholder="e.g. Niacinamide"
bind:value={active.name} bind:value={active.name}
/> />
</div> </div>
@ -651,7 +664,7 @@
size="sm" size="sm"
onclick={() => removeActive(i)} onclick={() => removeActive(i)}
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
><X class="size-3.5" /></Button> >✕</Button>
</div> </div>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<div class="space-y-1"> <div class="space-y-1">
@ -661,7 +674,7 @@
min="0" min="0"
max="100" max="100"
step="0.01" step="0.01"
placeholder={m["productForm_activePercentPlaceholder"]()} placeholder="e.g. 5"
bind:value={active.percent} bind:value={active.percent}
/> />
</div> </div>
@ -688,7 +701,7 @@
<div class="space-y-1"> <div class="space-y-1">
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label> <Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
{#each ingFunctions as fn (fn)} {#each ingFunctions as fn}
<label class="flex cursor-pointer items-center gap-1.5 text-xs"> <label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input <input
type="checkbox" type="checkbox"
@ -707,65 +720,367 @@
{#if actives.length === 0} {#if actives.length === 0}
<p class="text-sm text-muted-foreground">{m["productForm_noActives"]()}</p> <p class="text-sm text-muted-foreground">{m["productForm_noActives"]()}</p>
{/if} {/if}
{/if}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- ── Effect profile ─────────────────────────────────────────────────────── --> <!-- ── Effect profile ─────────────────────────────────────────────────────── -->
{#await import('$lib/components/product-form/ProductFormAssessmentSection.svelte') then mod} <Card>
{@const AssessmentSection = mod.default} <CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
<AssessmentSection <CardContent>
visible={editSection === 'assessment'} <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{selectClass} {#each effectFields as field}
{effectFields} {@const key = field.key as keyof typeof effectValues}
bind:effectValues <div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
{tristate} <span class="text-xs text-muted-foreground">{field.label}</span>
bind:ctxAfterShaving <input
bind:ctxAfterAcids type="range"
bind:ctxAfterRetinoids name="effect_{field.key}"
bind:ctxCompromisedBarrier min="0"
bind:ctxLowUvOnly max="5"
bind:fragranceFree step="1"
bind:essentialOilsFree bind:value={effectValues[key]}
bind:alcoholDenatFree class="accent-primary"
bind:pregnancySafe
/> />
{/await} <span class="text-center font-mono text-sm">{effectValues[key]}</span>
</div>
{/each}
</div>
</CardContent>
</Card>
{#await import('$lib/components/product-form/ProductFormDetailsSection.svelte') then mod} <!-- ── Interactions ───────────────────────────────────────────────────────── -->
{@const DetailsSection = mod.default} <Card>
<DetailsSection <CardHeader><CardTitle>{m["productForm_interactions"]()}</CardTitle></CardHeader>
visible={editSection === 'details'} <CardContent class="space-y-4">
{textareaClass} <div class="space-y-2">
bind:priceAmount <Label for="synergizes_with">{m["productForm_synergizesWith"]()}</Label>
bind:priceCurrency <textarea
bind:sizeMl id="synergizes_with"
bind:fullWeightG name="synergizes_with"
bind:emptyWeightG rows="3"
bind:paoMonths placeholder="Ceramides&#10;Niacinamide&#10;Retinoids"
bind:phMin class={textareaClass}
bind:phMax bind:value={synergizesWithText}
bind:usageNotes ></textarea>
bind:minIntervalHours </div>
bind:maxFrequencyPerWeek
bind:needleLengthMm
bind:isMedication
bind:isTool
{computedPriceLabel}
{computedPricePerUseLabel}
{computedPriceTierLabel}
/>
{/await}
{#await import('$lib/components/product-form/ProductFormNotesSection.svelte') then mod} <div class="space-y-3">
{@const NotesSection = mod.default} <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<NotesSection <Label>{m["productForm_incompatibleWith"]()}</Label>
visible={editSection === 'notes'} <Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{selectClass} {m["productForm_addIncompatibility"]()}
{textareaClass} </Button>
{tristate} </div>
bind:personalRepurchaseIntent
bind:personalToleranceNotes <input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i}
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]">
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
</div>
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompScope"]()}</Label>
<select class={selectClass} bind:value={row.scope}>
<option value="">{m["productForm_incompScopeSelect"]()}</option>
{#each interactionScopes as s}
<option value={s}>{scopeLabels[s]}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompReason"]()}</Label>
<Input placeholder={m["productForm_incompReasonPlaceholder"]()} bind:value={row.reason} />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeIncompatible(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div>
{/each}
{#if incompatibleWith.length === 0}
<p class="text-sm text-muted-foreground">{m["productForm_noIncompatibilities"]()}</p>
{/if}
</div>
</CardContent>
</Card>
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
<Card>
<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>{m["productForm_ctxAfterShaving"]()}</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}>
<SelectTrigger>{tristateLabel(ctxAfterShaving)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxAfterAcids"]()}</Label>
<input type="hidden" name="ctx_safe_after_acids" value={ctxAfterAcids} />
<Select type="single" value={ctxAfterAcids} onValueChange={(v) => (ctxAfterAcids = v)}>
<SelectTrigger>{tristateLabel(ctxAfterAcids)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxAfterRetinoids"]()}</Label>
<input type="hidden" name="ctx_safe_after_retinoids" value={ctxAfterRetinoids} />
<Select
type="single"
value={ctxAfterRetinoids}
onValueChange={(v) => (ctxAfterRetinoids = v)}
>
<SelectTrigger>{tristateLabel(ctxAfterRetinoids)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxCompromisedBarrier"]()}</Label>
<input type="hidden" name="ctx_safe_with_compromised_barrier" value={ctxCompromisedBarrier} />
<Select
type="single"
value={ctxCompromisedBarrier}
onValueChange={(v) => (ctxCompromisedBarrier = v)}
>
<SelectTrigger>{tristateLabel(ctxCompromisedBarrier)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxLowUvOnly"]()}</Label>
<input type="hidden" name="ctx_low_uv_only" value={ctxLowUvOnly} />
<Select type="single" value={ctxLowUvOnly} onValueChange={(v) => (ctxLowUvOnly = v)}>
<SelectTrigger>{tristateLabel(ctxLowUvOnly)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<!-- ── Product details ────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} />
<Select type="single" value={priceTier} onValueChange={(v) => (priceTier = v)}>
<SelectTrigger>{priceTier ? priceTierLabels[priceTier] : m["productForm_selectTier"]()}</SelectTrigger>
<SelectContent>
{#each priceTiers as p}
<SelectItem value={p}>{priceTierLabels[p]}</SelectItem>
{/each}
</SelectContent>
</Select>
</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="e.g. 50" 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="e.g. 120" 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="e.g. 30" 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="e.g. 12" bind:value={paoMonths} />
</div>
</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="e.g. 3.5" 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="e.g. 4.5" 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>
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
<Card>
<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>{m["productForm_fragranceFree"]()}</Label>
<input type="hidden" name="fragrance_free" value={fragranceFree} />
<Select type="single" value={fragranceFree} onValueChange={(v) => (fragranceFree = v)}>
<SelectTrigger>{tristateLabel(fragranceFree)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_essentialOilsFree"]()}</Label>
<input type="hidden" name="essential_oils_free" value={essentialOilsFree} />
<Select
type="single"
value={essentialOilsFree}
onValueChange={(v) => (essentialOilsFree = v)}
>
<SelectTrigger>{tristateLabel(essentialOilsFree)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_alcoholDenatFree"]()}</Label>
<input type="hidden" name="alcohol_denat_free" value={alcoholDenatFree} />
<Select
type="single"
value={alcoholDenatFree}
onValueChange={(v) => (alcoholDenatFree = v)}
>
<SelectTrigger>{tristateLabel(alcoholDenatFree)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_pregnancySafe"]()}</Label>
<input type="hidden" name="pregnancy_safe" value={pregnancySafe} />
<Select type="single" value={pregnancySafe} onValueChange={(v) => (pregnancySafe = v)}>
<SelectTrigger>{tristateLabel(pregnancySafe)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
<Card>
<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="e.g. 24" 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="e.g. 3" 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"
/> />
{/await} {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="e.g. 0.25" bind:value={needleLengthMm} />
</div>
</CardContent>
</Card>
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>{m["productForm_repurchaseIntent"]()}</Label>
<input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} />
<Select
type="single"
value={personalRepurchaseIntent}
onValueChange={(v) => (personalRepurchaseIntent = v)}
>
<SelectTrigger>{tristateLabel(personalRepurchaseIntent)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</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}
>{product?.personal_tolerance_notes ?? ''}</textarea>
</div>
</CardContent>
</Card>

View file

@ -1,62 +0,0 @@
<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

@ -1,24 +0,0 @@
<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

@ -1,54 +0,0 @@
<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

@ -1,44 +0,0 @@
<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

@ -1,45 +0,0 @@
<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

@ -1,49 +0,0 @@
<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

@ -1,5 +0,0 @@
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

@ -1,159 +0,0 @@
<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

@ -1,60 +0,0 @@
<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

@ -1,90 +0,0 @@
<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

@ -1,173 +0,0 @@
<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

@ -1,49 +0,0 @@
<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"; import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({ 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-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", 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",
variants: { variants: {
variant: { variant: {
default: default:
"bg-[var(--page-accent)] text-white [a&]:hover:brightness-95 border-transparent", "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary: secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent", "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive: 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", "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: "border-border bg-transparent text-foreground [a&]:hover:border-[color:var(--page-accent)] [a&]:hover:bg-[var(--page-accent-soft)]", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {

View file

@ -4,17 +4,17 @@
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({ 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 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", 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",
variants: { variants: {
variant: { variant: {
default: "border-transparent bg-[var(--page-accent)] text-white shadow-sm hover:brightness-95", default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive: destructive:
"border-transparent bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-white shadow-sm", "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",
outline: outline:
"border-border bg-card text-card-foreground shadow-sm hover:border-[color:var(--page-accent)] hover:bg-[var(--page-accent-soft)]", "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: "border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-[var(--page-accent-soft)]", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "border-transparent px-0 text-[var(--page-accent)] underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",

View file

@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
class={cn( class={cn(
"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)]", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className
)} )}
{...restProps} {...restProps}

View file

@ -25,7 +25,7 @@
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( 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,border-color] 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] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
@ -40,7 +40,7 @@
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( 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,border-color] 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] 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]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className

View file

@ -33,6 +33,7 @@ export type IngredientFunction =
| "prebiotic" | "prebiotic"
| "vitamin_c" | "vitamin_c"
| "anti_aging"; | "anti_aging";
export type InteractionScope = "same_step" | "same_day" | "same_period";
export type MedicationKind = export type MedicationKind =
| "prescription" | "prescription"
| "otc" | "otc"
@ -42,7 +43,6 @@ export type MedicationKind =
export type OverallSkinState = "excellent" | "good" | "fair" | "poor"; export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
export type PartOfDay = "am" | "pm"; export type PartOfDay = "am" | "pm";
export type PriceTier = "budget" | "mid" | "premium" | "luxury"; export type PriceTier = "budget" | "mid" | "premium" | "luxury";
export type PriceTierSource = "category" | "fallback" | "insufficient_data";
export type ProductCategory = export type ProductCategory =
| "cleanser" | "cleanser"
| "toner" | "toner"
@ -113,6 +113,12 @@ export interface ProductEffectProfile {
anti_aging_strength: number; anti_aging_strength: number;
} }
export interface ProductInteraction {
target: string;
scope: InteractionScope;
reason?: string;
}
export interface ProductContext { export interface ProductContext {
safe_after_shaving?: boolean; safe_after_shaving?: boolean;
safe_after_acids?: boolean; safe_after_acids?: boolean;
@ -148,11 +154,7 @@ export interface Product {
texture?: TextureType; texture?: TextureType;
absorption_speed?: AbsorptionSpeed; absorption_speed?: AbsorptionSpeed;
leave_on: boolean; leave_on: boolean;
price_amount?: number;
price_currency?: string;
price_tier?: PriceTier; price_tier?: PriceTier;
price_per_use_pln?: number;
price_tier_source?: PriceTierSource;
size_ml?: number; size_ml?: number;
full_weight_g?: number; full_weight_g?: number;
empty_weight_g?: number; empty_weight_g?: number;
@ -170,6 +172,8 @@ export interface Product {
product_effect_profile: ProductEffectProfile; product_effect_profile: ProductEffectProfile;
ph_min?: number; ph_min?: number;
ph_max?: number; ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext; context_rules?: ProductContext;
min_interval_hours?: number; min_interval_hours?: number;
max_frequency_per_week?: number; max_frequency_per_week?: number;

View file

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

View file

@ -1,137 +1,85 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; 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(); let { data }: { data: PageData } = $props();
const stateTone: Record<string, string> = { const stateColors: Record<string, string> = {
excellent: 'state-pill state-pill--excellent', excellent: 'bg-green-100 text-green-800',
good: 'state-pill state-pill--good', good: 'bg-blue-100 text-blue-800',
fair: 'state-pill state-pill--fair', fair: 'bg-yellow-100 text-yellow-800',
poor: 'state-pill state-pill--poor' poor: 'bg-red-100 text-red-800'
}; };
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> </script>
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
<div class="editorial-dashboard"> <div class="space-y-8">
<div class="editorial-atmosphere" aria-hidden="true"></div>
<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> <div>
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p> <h2 class="text-2xl font-bold tracking-tight">{m.dashboard_title()}</h2>
<p class="hero-strip-value">{snapshot.snapshot_date}</p> <p class="text-muted-foreground">{m.dashboard_subtitle()}</p>
</div>
{#if snapshot.overall_state}
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
{humanize(snapshot.overall_state)}
</span>
{/if}
</div>
{/if}
</section>
<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> </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} {#if data.latestSnapshot}
{@const s = data.latestSnapshot} {@const s = data.latestSnapshot}
<div class="snapshot-meta-row"> <div class="space-y-3">
<span class="snapshot-date">{s.snapshot_date}</span> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
{#if s.overall_state} {#if s.overall_state}
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span> <span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
{s.overall_state}
</span>
{/if} {/if}
</div> </div>
{#if s.active_concerns.length} {#if s.active_concerns.length}
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}> <div class="flex flex-wrap gap-1">
{#each s.active_concerns as concern (concern)} {#each s.active_concerns as concern (concern)}
<span class="concern-chip">{humanize(concern)}</span> <Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if s.notes} {#if s.notes}
<p class="snapshot-notes">{s.notes}</p> <p class="text-sm text-muted-foreground">{s.notes}</p>
{/if} {/if}
</div>
{:else} {:else}
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p> <p class="text-sm text-muted-foreground">{m["dashboard_noSnapshots"]()}</p>
{/if} {/if}
</section> </CardContent>
</Card>
<section class="editorial-panel reveal-3">
<header class="panel-header">
<p class="panel-index">02</p>
<h3>{m["dashboard_recentRoutines"]()}</h3>
</header>
<!-- Recent routines -->
<Card>
<CardHeader>
<CardTitle>{m["dashboard_recentRoutines"]()}</CardTitle>
</CardHeader>
<CardContent>
{#if data.recentRoutines.length} {#if data.recentRoutines.length}
<div class="routine-summary-strip"> <ul class="space-y-2">
<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)} {#each data.recentRoutines as routine (routine.id)}
<li class="routine-item"> <li class="flex items-center justify-between">
<a href={resolve(`/routines/${routine.id}`)} class="routine-link"> <a href="/routines/{routine.id}" class="text-sm hover:underline">
<div class="routine-main"> {routine.routine_date}
<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> </a>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</li> </li>
{/each} {/each}
</ol> </ul>
{:else} {:else}
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p> <p class="text-sm text-muted-foreground">{m["dashboard_noRoutines"]()}</p>
<div class="empty-actions">
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
</div>
{/if} {/if}
</section> </CardContent>
</Card>
</div> </div>
</div> </div>

View file

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

View file

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

View file

@ -6,8 +6,6 @@
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Sparkles, ArrowUp, ArrowDown } from 'lucide-svelte';
import { import {
Table, Table,
TableBody, TableBody,
@ -20,11 +18,7 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
type OwnershipFilter = 'all' | 'owned' | 'unowned'; type OwnershipFilter = 'all' | 'owned' | 'unowned';
type SortKey = 'brand' | 'name' | 'time' | 'price';
let ownershipFilter = $state<OwnershipFilter>('all'); let ownershipFilter = $state<OwnershipFilter>('all');
let sortKey = $state<SortKey>('brand');
let sortDirection = $state<'asc' | 'desc'>('asc');
let searchQuery = $state('');
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer', 'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
@ -35,59 +29,14 @@
return p.inventory?.some(inv => !inv.finished_at) ?? false; 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((() => { const groupedProducts = $derived((() => {
let items = data.products; let items = data.products;
if (ownershipFilter === 'owned') items = items.filter(isOwned); if (ownershipFilter === 'owned') items = items.filter(isOwned);
if (ownershipFilter === 'unowned') items = items.filter(p => !isOwned(p)); 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) => { items = [...items].sort((a, b) => {
let cmp = 0; const bc = a.brand.localeCompare(b.brand);
if (sortKey === 'brand') cmp = compareText(a.brand, b.brand); return bc !== 0 ? bc : a.name.localeCompare(b.name);
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[]>(); const map = new SvelteMap<string, Product[]>();
@ -106,46 +55,23 @@
})()); })());
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0)); 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> </script>
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="space-y-6">
<section class="editorial-hero reveal-1"> <div class="flex items-center justify-between">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <div>
<h2 class="editorial-title">{m.products_title()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p> <p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
<div class="editorial-toolbar"> </div>
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button> <div class="flex gap-2">
<Button href={resolve('/products/suggest')} variant="outline">{m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button> <Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div> </div>
</section> </div>
<div class="editorial-panel reveal-2"> <div class="flex flex-wrap gap-1">
<div class="editorial-filter-row">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)} {#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button <Button
variant={ownershipFilter === f ? 'default' : 'outline'} variant={ownershipFilter === f ? 'default' : 'outline'}
@ -157,37 +83,8 @@
{/each} {/each}
</div> </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 --> <!-- Desktop: table -->
<div class="products-table-shell hidden md:block reveal-2"> <div class="hidden rounded-md border border-border md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -195,27 +92,26 @@
<TableHead>{m["products_colBrand"]()}</TableHead> <TableHead>{m["products_colBrand"]()}</TableHead>
<TableHead>{m["products_colTargets"]()}</TableHead> <TableHead>{m["products_colTargets"]()}</TableHead>
<TableHead>{m["products_colTime"]()}</TableHead> <TableHead>{m["products_colTime"]()}</TableHead>
<TableHead>{m["products_colPricePerUse"]()}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#if totalCount === 0} {#if totalCount === 0}
<TableRow> <TableRow>
<TableCell colspan={5} class="text-center text-muted-foreground py-8"> <TableCell colspan={4} class="text-center text-muted-foreground py-8">
{m["products_noProducts"]()} {m["products_noProducts"]()}
</TableCell> </TableCell>
</TableRow> </TableRow>
{:else} {:else}
{#each groupedProducts as [category, products] (category)} {#each groupedProducts as [category, products] (category)}
<TableRow class="products-category-row"> <TableRow class="bg-muted/30 hover:bg-muted/30">
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide"> <TableCell colspan={4} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{formatCategory(category)} {category.replace(/_/g, ' ')}
</TableCell> </TableCell>
</TableRow> </TableRow>
{#each products as product (product.id)} {#each products as product (product.id)}
<TableRow class={`cursor-pointer ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/25 text-muted-foreground hover:bg-muted/35'}`}> <TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell class="max-w-[32rem] align-top whitespace-normal"> <TableCell>
<a href={resolve(`/products/${product.id}`)} title={product.name} class="block break-words line-clamp-2 font-medium hover:underline"> <a href="/products/{product.id}" class="font-medium hover:underline">
{product.name} {product.name}
</a> </a>
</TableCell> </TableCell>
@ -231,12 +127,6 @@
</div> </div>
</TableCell> </TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</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> </TableRow>
{/each} {/each}
{/each} {/each}
@ -246,27 +136,23 @@
</div> </div>
<!-- Mobile: cards --> <!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden reveal-3"> <div class="flex flex-col gap-3 md:hidden">
{#if totalCount === 0} {#if totalCount === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p> <p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else} {:else}
{#each groupedProducts as [category, products] (category)} {#each groupedProducts as [category, products] (category)}
<div class="products-section-title"> <div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{formatCategory(category)} {category.replace(/_/g, ' ')}
</div> </div>
{#each products as product (product.id)} {#each products as product (product.id)}
<a <a
href={resolve(`/products/${product.id}`)} href="/products/{product.id}"
class={`products-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`} class="block rounded-lg border border-border p-4 hover:bg-muted/50"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div>
<p class="break-words line-clamp-2 font-medium" title={product.name}>{product.name}</p> <p class="font-medium">{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</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> </div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span> <span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
</div> </div>

View file

@ -119,19 +119,17 @@ export const actions: Actions = {
}; };
// Optional strings // Optional strings
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) { for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
const v = parseOptionalString(form.get(field) as string | null); const v = parseOptionalString(form.get(field) as string | null);
body[field] = v ?? null; body[field] = v ?? null;
} }
// Optional enum selects (null if empty = clearing the value) // Optional enum selects (null if empty = clearing the value)
for (const field of ['texture', 'absorption_speed']) { for (const field of ['texture', 'absorption_speed', 'price_tier']) {
const v = form.get(field) as string | null; const v = form.get(field) as string | null;
body[field] = v || null; body[field] = v || null;
} }
body.price_amount = parseOptionalFloat(form.get('price_amount') as string | null) ?? null;
// Optional numbers // Optional numbers
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null; 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; body.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null;
@ -168,6 +166,23 @@ export const actions: Actions = {
body.actives = null; 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 // Context rules
body.context_rules = parseContextRules(form) ?? null; body.context_rules = parseContextRules(form) ?? null;

View file

@ -5,11 +5,10 @@
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; 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 { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Separator } from '$lib/components/ui/separator';
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte'; import ProductForm from '$lib/components/ProductForm.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -17,115 +16,37 @@
let showInventoryForm = $state(false); let showInventoryForm = $state(false);
let editingInventoryId = $state<string | null>(null); 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> </script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head> <svelte:head><title>{product.name} — innercontext</title></svelte:head>
<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"> <div class="max-w-2xl space-y-6">
<Button <div>
type="submit" <a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
form="product-edit-form" <h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
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>
<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} {#if form?.error}
<div class="editorial-alert editorial-alert--error">{form.error}</div> <div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if} {/if}
{#if form?.success} {#if form?.success}
<div class="editorial-alert editorial-alert--success">{m.common_saved()}</div> <div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
{/if} {/if}
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2"> <!-- Edit form -->
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap"> <form method="POST" action="?/update" use:enhance class="space-y-6">
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}> <ProductForm {product} />
<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>
<TabsContent value="inventory" class="space-y-4 pt-2"> <div class="flex gap-3">
<Button type="submit">{m["products_saveChanges"]()}</Button>
</div>
</form>
<Separator />
<!-- Inventory -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3> <h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}> <Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()} {showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
@ -133,23 +54,20 @@
</div> </div>
{#if form?.inventoryAdded} {#if form?.inventoryAdded}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div> <div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageAdded"]()}</div>
{/if} {/if}
{#if form?.inventoryUpdated} {#if form?.inventoryUpdated}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div> <div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageUpdated"]()}</div>
{/if} {/if}
{#if form?.inventoryDeleted} {#if form?.inventoryDeleted}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div> <div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageDeleted"]()}</div>
{/if} {/if}
{#if showInventoryForm} {#if showInventoryForm}
<Card> <Card>
<CardHeader class="pb-2"> <CardContent class="pt-4">
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle> <form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
</CardHeader> <div class="col-span-2 flex items-center gap-2">
<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" /> <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> <Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div> </div>
@ -162,20 +80,20 @@
<Input id="add_finished_at" name="finished_at" type="date" /> <Input id="add_finished_at" name="finished_at" type="date" />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label> <Label for="expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="add_expiry_date" name="expiry_date" type="date" /> <Input id="expiry_date" name="expiry_date" type="date" />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label> <Label for="current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" /> <Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label> <Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" /> <Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="add_notes">{m.inventory_notes()}</Label> <Label for="notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" /> <Input id="notes" name="notes" />
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<Button type="submit" size="sm">{m.common_add()}</Button> <Button type="submit" size="sm">{m.common_add()}</Button>
@ -189,8 +107,8 @@
<div class="space-y-2"> <div class="space-y-2">
{#each product.inventory as pkg (pkg.id)} {#each product.inventory as pkg (pkg.id)}
<div class="rounded-md border border-border text-sm"> <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 items-center justify-between px-4 py-3">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}> <Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()} {pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
</Badge> </Badge>
@ -231,7 +149,7 @@
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }} onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
> >
<input type="hidden" name="inventory_id" value={pkg.id} /> <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> <Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">×</Button>
</form> </form>
</div> </div>
</div> </div>
@ -247,10 +165,10 @@
if (result.type === 'success') editingInventoryId = null; if (result.type === 'success') editingInventoryId = null;
}; };
}} }}
class="grid grid-cols-1 gap-4 sm:grid-cols-2" class="grid grid-cols-2 gap-4"
> >
<input type="hidden" name="inventory_id" value={pkg.id} /> <input type="hidden" name="inventory_id" value={pkg.id} />
<div class="sm:col-span-2 flex items-center gap-2"> <div class="col-span-2 flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
id="edit_is_opened_{pkg.id}" id="edit_is_opened_{pkg.id}"
@ -263,23 +181,49 @@
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label> <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) ?? ''} /> <Input
id="edit_opened_at_{pkg.id}"
name="opened_at"
type="date"
value={pkg.opened_at?.slice(0, 10) ?? ''}
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label> <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) ?? ''} /> <Input
id="edit_finished_at_{pkg.id}"
name="finished_at"
type="date"
value={pkg.finished_at?.slice(0, 10) ?? ''}
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label> <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) ?? ''} /> <Input
id="edit_expiry_{pkg.id}"
name="expiry_date"
type="date"
value={pkg.expiry_date?.slice(0, 10) ?? ''}
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label> <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 ?? ''} /> <Input
id="edit_weight_{pkg.id}"
name="current_weight_g"
type="number"
min="0"
value={pkg.current_weight_g ?? ''}
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label> <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) ?? ''} /> <Input
id="edit_last_weighed_{pkg.id}"
name="last_weighed_at"
type="date"
value={pkg.last_weighed_at?.slice(0, 10) ?? ''}
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label> <Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
@ -287,7 +231,12 @@
</div> </div>
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<Button type="submit" size="sm">{m.common_save()}</Button> <Button type="submit" size="sm">{m.common_save()}</Button>
<Button type="button" variant="ghost" size="sm" onclick={() => (editingInventoryId = null)}>{m.common_cancel()}</Button> <Button
type="button"
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = null)}
>{m.common_cancel()}</Button>
</div> </div>
</form> </form>
</div> </div>
@ -299,48 +248,20 @@
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p> <p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
{/if} {/if}
</div> </div>
</TabsContent>
<TabsContent value="edit" class="space-y-4 pt-2"> <Separator />
<Card>
<CardHeader class="pb-2"> <!-- Danger zone -->
<div class="flex items-center justify-between gap-2"> <div>
<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 <form
id="product-edit-form"
method="POST" method="POST"
action="?/update" action="?/delete"
use:enhance={() => { use:enhance
return async ({ result, update }) => { onsubmit={(e) => {
await update(); if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
if (result.type === 'success') {
isEditDirty = false;
editSaveVersion += 1;
}
};
}} }}
class="space-y-6"
> >
<ProductForm <Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
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> </form>
</CardContent> </div>
</Card>
</TabsContent>
</Tabs>
</div> </div>

View file

@ -107,20 +107,17 @@ export const actions: Actions = {
}; };
// Optional strings // Optional strings
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) { for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
const v = parseOptionalString(form.get(field) as string | null); const v = parseOptionalString(form.get(field) as string | null);
if (v !== undefined) payload[field] = v; if (v !== undefined) payload[field] = v;
} }
// Optional enum selects // Optional enum selects
for (const field of ['texture', 'absorption_speed']) { for (const field of ['texture', 'absorption_speed', 'price_tier']) {
const v = form.get(field) as string | null; const v = form.get(field) as string | null;
if (v) payload[field] = v; 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 // Optional numbers
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null); const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
if (size_ml !== undefined) payload.size_ml = size_ml; if (size_ml !== undefined) payload.size_ml = size_ml;
@ -170,6 +167,19 @@ export const actions: Actions = {
} }
} catch { /* ignore malformed JSON */ } } 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 // Context rules
const contextRules = parseContextRules(form); const contextRules = parseContextRules(form);
if (contextRules) payload.context_rules = contextRules; if (contextRules) payload.context_rules = contextRules;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,23 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import type { Plugin, Rollup } from 'vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { paraglideVitePlugin } from '@inlang/paraglide-js'; 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({ export default defineConfig({
plugins: [ plugins: [
stripDeprecatedRollupOptions,
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }), paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }),
tailwindcss(), tailwindcss(),
sveltekit() sveltekit()