Compare commits

..

5 commits

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import json import json
from datetime import date from datetime import date
from typing import Optional from typing import Literal, 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,6 +17,7 @@ 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,
@ -38,7 +39,6 @@ from innercontext.models.product import (
ActiveIngredient, ActiveIngredient,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
) )
router = APIRouter() router = APIRouter()
@ -68,8 +68,11 @@ class ProductUpdate(SQLModel):
absorption_speed: Optional[AbsorptionSpeed] = None absorption_speed: Optional[AbsorptionSpeed] = None
leave_on: Optional[bool] = None leave_on: Optional[bool] = None
price_tier: Optional[PriceTier] = None price_amount: Optional[float] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None 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
@ -91,8 +94,6 @@ 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
@ -122,7 +123,8 @@ 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_tier: Optional[PriceTier] = None price_amount: Optional[float] = 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
@ -140,8 +142,6 @@ 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,6 +218,188 @@ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -272,8 +454,12 @@ 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
@ -281,9 +467,13 @@ 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(),
**data.model_dump(), **payload,
) )
session.add(product) session.add(product)
session.commit() session.commit()
@ -329,8 +519,6 @@ 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"
@ -347,8 +535,6 @@ 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,
@ -362,7 +548,8 @@ 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_tier": string, "price_amount": number,
"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,
@ -402,10 +589,6 @@ 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,
@ -451,10 +634,14 @@ 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
@ -464,12 +651,19 @@ 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)
for key, value in data.model_dump(exclude_unset=True).items(): patch_data = data.model_dump(exclude_unset=True)
if patch_data.get("price_currency"):
patch_data["price_currency"] = str(patch_data["price_currency"]).upper()
for key, value in patch_data.items():
setattr(product, key, value) setattr(product, key, value)
session.add(product) session.add(product)
session.commit() session.commit()
session.refresh(product) session.refresh(product)
return product pricing_pool = list(session.exec(select(Product)).all())
pricing_outputs = _compute_pricing_outputs(pricing_pool)
result = ProductPublic.model_validate(product, from_attributes=True)
return _with_pricing(result, pricing_outputs.get(product.id, (None, None, None)))
@router.delete("/{product_id}", status_code=204) @router.delete("/{product_id}", status_code=204)
@ -721,7 +915,6 @@ 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 {},
@ -755,7 +948,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 incompatible_with, contraindications, context_rules and safety flags." "including 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,8 +378,6 @@ 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"):
@ -501,7 +499,6 @@ 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 {},
@ -630,7 +627,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: "
"incompatible_with, contraindications, context_rules and safety flags." "contraindications, context_rules and safety flags."
), ),
parameters=genai_types.Schema( parameters=genai_types.Schema(
type=genai_types.Type.OBJECT, type=genai_types.Type.OBJECT,
@ -688,8 +685,7 @@ 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: incompatible_with (same_step / same_day / same_period), context_rules, - Respektuj: context_rules, min_interval_hours, max_frequency_per_week, usage_notes.
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,7 +7,6 @@ from .enums import (
EvidenceLevel, EvidenceLevel,
GroomingAction, GroomingAction,
IngredientFunction, IngredientFunction,
InteractionScope,
MedicationKind, MedicationKind,
OverallSkinState, OverallSkinState,
PartOfDay, PartOfDay,
@ -29,7 +28,6 @@ from .product import (
ProductBase, ProductBase,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
ProductInventory, ProductInventory,
ProductPublic, ProductPublic,
ProductWithInventory, ProductWithInventory,
@ -53,7 +51,6 @@ __all__ = [
"EvidenceLevel", "EvidenceLevel",
"GroomingAction", "GroomingAction",
"IngredientFunction", "IngredientFunction",
"InteractionScope",
"MedicationKind", "MedicationKind",
"OverallSkinState", "OverallSkinState",
"PartOfDay", "PartOfDay",
@ -77,7 +74,6 @@ __all__ = [
"ProductBase", "ProductBase",
"ProductContext", "ProductContext",
"ProductEffectProfile", "ProductEffectProfile",
"ProductInteraction",
"ProductInventory", "ProductInventory",
"ProductPublic", "ProductPublic",
"ProductWithInventory", "ProductWithInventory",

View file

@ -131,12 +131,6 @@ 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,7 +12,6 @@ from .enums import (
AbsorptionSpeed, AbsorptionSpeed,
DayTime, DayTime,
IngredientFunction, IngredientFunction,
InteractionScope,
PriceTier, PriceTier,
ProductCategory, ProductCategory,
SkinConcern, SkinConcern,
@ -57,12 +56,6 @@ 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
@ -101,7 +94,8 @@ class ProductBase(SQLModel):
absorption_speed: AbsorptionSpeed | None = None absorption_speed: AbsorptionSpeed | None = None
leave_on: bool leave_on: bool
price_tier: PriceTier | None = None price_amount: float | None = Field(default=None, gt=0)
price_currency: str | None = Field(default=None, min_length=3, max_length=3)
size_ml: float | None = Field(default=None, gt=0) 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)
@ -128,8 +122,6 @@ 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)
@ -154,9 +146,6 @@ 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)
@ -181,12 +170,6 @@ 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)
) )
@ -232,9 +215,17 @@ 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(self) -> dict: def to_llm_context(
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,
@ -253,8 +244,14 @@ 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_tier is not None: if self.price_amount is not None:
ctx["price_tier"] = _ev(self.price_tier) ctx["price_amount"] = self.price_amount
if self.price_currency is not None:
ctx["price_currency"] = self.price_currency
if computed_price_tier is not None:
ctx["price_tier"] = _ev(computed_price_tier)
if price_per_use_pln is not None:
ctx["price_per_use_pln"] = round(price_per_use_pln, 4)
if self.size_ml is not None: 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:
@ -302,26 +299,6 @@ 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):
@ -405,6 +382,9 @@ 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

@ -0,0 +1,77 @@
import json
from datetime import datetime, timedelta, timezone
from threading import Lock
from urllib.error import URLError
from urllib.request import Request, urlopen
NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A"
_CACHE_TTL = timedelta(hours=24)
_cache_lock = Lock()
_cached_rates: dict[str, float] | None = None
_cached_at: datetime | None = None
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _cache_is_fresh() -> bool:
if _cached_rates is None or _cached_at is None:
return False
return _now_utc() - _cached_at < _CACHE_TTL
def _fetch_rates_from_nbp() -> dict[str, float]:
req = Request(NBP_TABLE_A_URL, headers={"Accept": "application/json"})
with urlopen(req, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
if not isinstance(payload, list) or not payload:
raise ValueError("Unexpected NBP payload")
table = payload[0]
rates = table.get("rates") if isinstance(table, dict) else None
if not isinstance(rates, list):
raise ValueError("NBP payload does not include rates")
parsed: dict[str, float] = {"PLN": 1.0}
for row in rates:
if not isinstance(row, dict):
continue
code = row.get("code")
mid = row.get("mid")
if not isinstance(code, str) or not isinstance(mid, (int, float)):
continue
parsed[code.upper()] = float(mid)
return parsed
def get_pln_rates() -> dict[str, float]:
global _cached_rates, _cached_at
with _cache_lock:
if _cache_is_fresh() and _cached_rates is not None:
return dict(_cached_rates)
try:
fresh = _fetch_rates_from_nbp()
except (URLError, TimeoutError, ValueError):
if _cached_rates is not None:
return dict(_cached_rates)
return {"PLN": 1.0}
_cached_rates = fresh
_cached_at = _now_utc()
return dict(fresh)
def convert_to_pln(amount: float, currency: str) -> float | None:
if amount <= 0:
return None
rates = get_pln_rates()
rate = rates.get(currency.upper())
if rate is None:
return None
return amount * rate

View file

@ -8,7 +8,7 @@ dependencies = [
"alembic>=1.14", "alembic>=1.14",
"fastapi>=0.132.0", "fastapi>=0.132.0",
"google-genai>=1.65.0", "google-genai>=1.65.0",
"psycopg>=3.3.3", "psycopg[binary]>=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,14 +7,12 @@ 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,
) )
@ -156,29 +154,6 @@ 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,7 +136,6 @@ 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={},
) )
@ -153,5 +152,4 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin." 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

@ -0,0 +1,177 @@
import uuid
from innercontext.api import products as products_api
from innercontext.models import Product
from innercontext.models.enums import DayTime, ProductCategory
def _product(
*, category: ProductCategory, price_amount: float, size_ml: float
) -> Product:
return Product(
id=uuid.uuid4(),
name=f"{category}-{price_amount}",
brand="Brand",
category=category,
recommended_time=DayTime.BOTH,
leave_on=True,
price_amount=price_amount,
price_currency="PLN",
size_ml=size_ml,
)
def test_compute_pricing_outputs_groups_by_category(monkeypatch):
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
serums = [
_product(category=ProductCategory.SERUM, price_amount=float(i), size_ml=35.0)
for i in range(10, 90, 10)
]
cleansers = [
_product(
category=ProductCategory.CLEANSER, price_amount=float(i), size_ml=200.0
)
for i in range(40, 120, 10)
]
outputs = products_api._compute_pricing_outputs(serums + cleansers)
serum_tiers = [outputs[p.id][0] for p in serums]
cleanser_tiers = [outputs[p.id][0] for p in cleansers]
assert serum_tiers[0] == "budget"
assert serum_tiers[-1] == "luxury"
assert cleanser_tiers[0] == "budget"
assert cleanser_tiers[-1] == "luxury"
def test_price_tier_is_null_when_not_enough_products(client, monkeypatch):
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
base = {
"brand": "B",
"recommended_time": "both",
"leave_on": True,
"size_ml": 35.0,
"price_currency": "PLN",
}
for i in range(7):
response = client.post(
"/products",
json={
**base,
"name": f"Serum {i}",
"category": "serum",
"price_amount": 30 + i,
},
)
assert response.status_code == 201
products = client.get("/products").json()
assert len(products) == 7
assert all(p["price_tier"] is None for p in products)
assert all(p["price_per_use_pln"] is not None for p in products)
def test_price_tier_is_computed_on_list(client, monkeypatch):
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
base = {
"brand": "B",
"recommended_time": "both",
"leave_on": True,
"size_ml": 35.0,
"price_currency": "PLN",
"category": "serum",
}
for i in range(8):
response = client.post(
"/products",
json={**base, "name": f"Serum {i}", "price_amount": 20 + i * 10},
)
assert response.status_code == 201
products = client.get("/products").json()
assert len(products) == 8
assert any(p["price_tier"] == "budget" for p in products)
assert any(p["price_tier"] == "luxury" for p in products)
def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch):
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
serum_base = {
"brand": "B",
"recommended_time": "both",
"leave_on": True,
"size_ml": 35.0,
"price_currency": "PLN",
"category": "serum",
}
for i in range(8):
response = client.post(
"/products",
json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5},
)
assert response.status_code == 201
toner_base = {
"brand": "B",
"recommended_time": "both",
"leave_on": True,
"size_ml": 100.0,
"price_currency": "PLN",
"category": "toner",
}
for i in range(5):
response = client.post(
"/products",
json={**toner_base, "name": f"Toner {i}", "price_amount": 10 + i * 5},
)
assert response.status_code == 201
products = client.get("/products?category=toner").json()
assert len(products) == 5
assert all(p["price_tier"] is not None for p in products)
assert all(p["price_tier_source"] == "fallback" for p in products)
def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool(
client, monkeypatch
):
monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount)
serum_base = {
"brand": "B",
"recommended_time": "both",
"leave_on": True,
"size_ml": 35.0,
"price_currency": "PLN",
"category": "serum",
}
for i in range(8):
response = client.post(
"/products",
json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5},
)
assert response.status_code == 201
oil_base = {
"brand": "B",
"recommended_time": "both",
"leave_on": True,
"size_ml": 30.0,
"price_currency": "PLN",
"category": "oil",
}
for i in range(3):
response = client.post(
"/products",
json={**oil_base, "name": f"Oil {i}", "price_amount": 30 + i * 10},
)
assert response.status_code == 201
oils = client.get("/products?category=oil").json()
assert len(oils) == 3
assert all(p["price_tier"] is None for p in oils)
assert all(p["price_tier_source"] == "insufficient_data" for p in oils)

View file

@ -184,7 +184,6 @@ def test_build_products_context(session: Session):
recommended_time="am", 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,
@ -233,7 +232,6 @@ 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
@ -390,7 +388,6 @@ 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={},
) )
@ -404,5 +401,4 @@ def test_additional_tool_handlers_return_product_payloads(session: Session):
assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening." 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" }, { name = "psycopg", extra = ["binary"] },
{ 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", specifier = ">=3.3.3" }, { name = "psycopg", extras = ["binary"], 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,6 +739,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, { 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

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

View file

@ -22,6 +22,12 @@
"common_unknown_value": "Unknown", "common_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",
@ -61,8 +67,9 @@
"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",
@ -106,7 +113,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 *",
@ -124,12 +131,15 @@
"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.",
@ -152,7 +162,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",
@ -165,6 +175,8 @@
"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",
@ -258,6 +270,7 @@
"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",
@ -336,7 +349,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 fields (AI)", "productForm_parseWithAI": "Fill",
"productForm_parsing": "Processing…", "productForm_parsing": "Processing…",
"productForm_basicInfo": "Basic info", "productForm_basicInfo": "Basic info",
"productForm_name": "Name *", "productForm_name": "Name *",
@ -346,6 +359,7 @@
"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",
@ -370,25 +384,18 @@
"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",
@ -397,6 +404,19 @@
"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)",
@ -495,10 +515,6 @@
"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,6 +22,12 @@
"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",
@ -63,8 +69,9 @@
"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",
@ -110,7 +117,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 *",
@ -128,12 +135,15 @@
"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.",
@ -156,7 +166,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",
@ -169,6 +179,8 @@
"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",
@ -270,6 +282,7 @@
"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",
@ -350,7 +363,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 pola (AI)", "productForm_parseWithAI": "Uzupełnij",
"productForm_parsing": "Przetwarzam…", "productForm_parsing": "Przetwarzam…",
"productForm_basicInfo": "Informacje podstawowe", "productForm_basicInfo": "Informacje podstawowe",
"productForm_name": "Nazwa *", "productForm_name": "Nazwa *",
@ -360,6 +373,7 @@
"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",
@ -384,25 +398,18 @@
"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",
@ -411,6 +418,19 @@
"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)",
@ -509,10 +529,6 @@
"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,26 +5,42 @@
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */ /* ── CSS variable definitions (light / dark) ─────────────────────────────── */
:root { :root {
--background: hsl(0 0% 100%); --background: hsl(42 35% 95%);
--foreground: hsl(240 10% 3.9%); --foreground: hsl(220 24% 14%);
--card: hsl(0 0% 100%); --card: hsl(44 32% 96%);
--card-foreground: hsl(240 10% 3.9%); --card-foreground: hsl(220 24% 14%);
--popover: hsl(0 0% 100%); --popover: hsl(44 32% 96%);
--popover-foreground: hsl(240 10% 3.9%); --popover-foreground: hsl(220 24% 14%);
--primary: hsl(240 5.9% 10%); --primary: hsl(15 44% 34%);
--primary-foreground: hsl(0 0% 98%); --primary-foreground: hsl(42 40% 97%);
--secondary: hsl(240 4.8% 95.9%); --secondary: hsl(38 24% 91%);
--secondary-foreground: hsl(240 5.9% 10%); --secondary-foreground: hsl(220 20% 20%);
--muted: hsl(240 4.8% 95.9%); --muted: hsl(42 20% 90%);
--muted-foreground: hsl(240 3.8% 46.1%); --muted-foreground: hsl(219 12% 39%);
--accent: hsl(240 4.8% 95.9%); --accent: hsl(42 24% 90%);
--accent-foreground: hsl(240 5.9% 10%); --accent-foreground: hsl(220 24% 14%);
--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(240 5.9% 90%); --border: hsl(35 23% 76%);
--input: hsl(240 5.9% 90%); --input: hsl(37 20% 80%);
--ring: hsl(240 5.9% 10%); --ring: hsl(15 40% 38%);
--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 {
@ -86,4 +102,723 @@
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,6 +3,9 @@
<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,7 +11,6 @@ import type {
Product, Product,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
ProductInventory, ProductInventory,
Routine, Routine,
RoutineSuggestion, RoutineSuggestion,
@ -109,7 +108,8 @@ export interface ProductParseResponse {
texture?: string; texture?: string;
absorption_speed?: string; absorption_speed?: string;
leave_on?: boolean; leave_on?: boolean;
price_tier?: string; price_amount?: number;
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,8 +127,6 @@ 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,16 +1,34 @@
<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, InteractionScope } from '$lib/types'; import type { IngredientFunction } 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 { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { parseProductText, type ProductParseResponse } from '$lib/api'; import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
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 { product }: { product?: Product } = $props(); let {
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 ─────────────────────────────────────────────────────
@ -20,7 +38,6 @@
]; ];
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',
@ -34,7 +51,6 @@
'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 ─────────────────────────────────────────────────
@ -72,13 +88,6 @@
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"](),
@ -125,22 +134,12 @@
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"]() },
@ -177,7 +176,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 synergizesWithText = $state(untrack(() => product?.synergizes_with?.join('\n') ?? '')); let personalToleranceNotes = $state(untrack(() => product?.personal_tolerance_notes ?? ''));
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 ?? [])]));
@ -186,19 +185,22 @@
// ── AI pre-fill state ───────────────────────────────────────────────────── // ── AI pre-fill state ─────────────────────────────────────────────────────
let aiPanelOpen = $state(false); let aiModalOpen = $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);
aiPanelOpen = false; aiModalOpen = false;
} catch (e) { } catch (e) {
aiError = (e as Error).message; aiError = (e as Error).message;
} finally { } finally {
@ -218,7 +220,8 @@
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_tier) priceTier = r.price_tier; if (r.price_amount != null) priceAmount = String(r.price_amount);
if (r.price_currency) priceCurrency = r.price_currency;
if (r.leave_on != null) leaveOn = String(r.leave_on); if (r.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);
@ -239,7 +242,6 @@
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,
@ -249,13 +251,6 @@
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 };
} }
@ -276,7 +271,8 @@
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 priceTier = $state(untrack(() => product?.price_tier ?? '')); let priceAmount = $state(untrack(() => (product?.price_amount != null ? String(product.price_amount) : '')));
let priceCurrency = $state(untrack(() => product?.price_currency ?? 'PLN'));
let fragranceFree = $state( let fragranceFree = $state(
untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : '')) untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : ''))
); );
@ -380,192 +376,175 @@
) )
); );
// ── Dynamic incompatible_with ───────────────────────────────────────────── const textareaClass = `${baseTextareaClass} focus-visible:ring-offset-2`;
type IncompatibleRow = { target: string; scope: string; reason: string }; const selectClass = baseSelectClass;
let incompatibleWith: IncompatibleRow[] = $state( export function openAiModal() {
untrack(() => aiError = '';
product?.incompatible_with?.map((i) => ({ aiModalOpen = true;
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
})) ?? []
)
);
function addIncompatible() {
incompatibleWith = [...incompatibleWith, { target: '', scope: '', reason: '' }];
} }
function removeIncompatible(i: number) { function closeAiModal() {
incompatibleWith = incompatibleWith.filter((_, idx) => idx !== i); if (aiLoading) return;
aiModalOpen = false;
} }
let incompatibleJson = $derived( function handleModalKeydown(event: KeyboardEvent) {
JSON.stringify( if (!aiModalOpen) return;
incompatibleWith if (event.key === 'Escape') {
.filter((i) => i.target.trim() && i.scope) event.preventDefault();
.map((i) => ({ closeAiModal();
target: i.target.trim(), }
scope: i.scope, }
...(i.reason.trim() ? { reason: i.reason.trim() } : {})
})) 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
})
); );
const textareaClass = let baselineFingerprint = $state('');
'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'; let baselineProductId = $state('');
let baselineSaveVersion = $state(-1);
const selectClass = $effect(() => {
'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'; 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);
});
</script> </script>
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── --> <svelte:window onkeydown={handleModalKeydown} />
<Card>
<CardHeader> <Tabs bind:value={editSection} class="space-y-2">
<button type="button" class="flex w-full items-center justify-between text-left" <TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
onclick={() => (aiPanelOpen = !aiPanelOpen)}> <TabsTrigger value="basic" class="shrink-0 px-3">{m["productForm_basicInfo"]()}</TabsTrigger>
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle> <TabsTrigger value="ingredients" class="shrink-0 px-3">{m["productForm_ingredients"]()}</TabsTrigger>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span> <TabsTrigger value="assessment" class="shrink-0 px-3">{m["productForm_effectProfile"]()}</TabsTrigger>
</button> <TabsTrigger value="details" class="shrink-0 px-3">{m["productForm_productDetails"]()}</TabsTrigger>
</CardHeader> <TabsTrigger value="notes" class="shrink-0 px-3">{m["productForm_personalNotes"]()}</TabsTrigger>
{#if aiPanelOpen} </TabsList>
<CardContent class="space-y-3"> </Tabs>
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
<textarea bind:value={aiText} rows="6" {#if showAiTrigger}
placeholder={m["productForm_pasteText"]()} <div class="flex justify-end">
class={textareaClass}></textarea> <Button type="button" variant="outline" size="sm" onclick={openAiModal}>
{#if aiError} <Sparkles class="size-4" />
<p class="text-sm text-destructive">{aiError}</p> {m["productForm_aiPrefill"]()}
{/if}
<Button type="button" onclick={parseWithAi}
disabled={aiLoading || !aiText.trim()}>
{aiLoading ? m["productForm_parsing"]() : m["productForm_parseWithAI"]()}
</Button> </Button>
</CardContent> </div>
{/if} {/if}
</Card>
<!-- ── Basic info ──────────────────────────────────────────────────────────── --> {#if aiModalOpen}
<Card> {#await import('$lib/components/ProductFormAiModal.svelte') then mod}
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader> {@const AiModal = mod.default}
<CardContent class="space-y-4"> <AiModal
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> open={aiModalOpen}
<div class="space-y-2"> bind:aiText
<Label for="name">{m["productForm_name"]()}</Label> {aiLoading}
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} /> aiError={aiError}
</div> {textareaClass}
<div class="space-y-2"> onClose={closeAiModal}
<Label for="brand">{m["productForm_brand"]()}</Label> onSubmit={parseWithAi}
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} /> />
</div> {/await}
</div> {/if}
<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>
<!-- ── Classification ────────────────────────────────────────────────────── --> {#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod}
<Card> {@const BasicSection = mod.default}
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader> <BasicSection
<CardContent> visible={editSection === 'basic'}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> bind:name
<div class="col-span-2 space-y-2"> bind:brand
<Label>{m["productForm_category"]()}</Label> bind:lineName
<input type="hidden" name="category" value={category} /> bind:url
<Select type="single" value={category} onValueChange={(v) => (category = v)}> bind:sku
<SelectTrigger>{category ? categoryLabels[category] : m["productForm_selectCategory"]()}</SelectTrigger> bind:barcode
<SelectContent> />
{#each categories as cat} {/await}
<SelectItem value={cat}>{categoryLabels[cat]}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2"> {#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod}
<Label>{m["productForm_time"]()}</Label> {@const ClassificationSection = mod.default}
<input type="hidden" name="recommended_time" value={recommendedTime} /> <ClassificationSection
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}> visible={editSection === 'basic'}
<SelectTrigger> {selectClass}
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()} {categories}
</SelectTrigger> {textures}
<SelectContent> {absorptionSpeeds}
<SelectItem value="am">AM</SelectItem> {categoryLabels}
<SelectItem value="pm">PM</SelectItem> {textureLabels}
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem> {absorptionLabels}
</SelectContent> bind:category
</Select> bind:recommendedTime
</div> bind:leaveOn
bind:texture
<div class="space-y-2"> bind:absorptionSpeed
<Label>{m["productForm_leaveOn"]()}</Label> />
<input type="hidden" name="leave_on" value={leaveOn} /> {/await}
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
<SelectTrigger>{leaveOn === 'true' ? m["productForm_leaveOnYes"]() : m["productForm_leaveOnNo"]()}</SelectTrigger>
<SelectContent>
<SelectItem value="true">{m["productForm_leaveOnYes"]()}</SelectItem>
<SelectItem value="false">{m["productForm_leaveOnNo"]()}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_texture"]()}</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture ? textureLabels[texture] : m["productForm_selectTexture"]()}</SelectTrigger>
<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> <Card class={editSection === 'ingredients' ? '' : 'hidden'}>
<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} {#each skinTypes as st (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"
@ -589,7 +568,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} {#each skinConcerns as sc (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"
@ -625,7 +604,7 @@
</Card> </Card>
<!-- ── Ingredients ────────────────────────────────────────────────────────── --> <!-- ── Ingredients ────────────────────────────────────────────────────────── -->
<Card> <Card class={editSection === 'ingredients' ? '' : 'hidden'}>
<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">
@ -634,7 +613,7 @@
id="inci" id="inci"
name="inci" name="inci"
rows="5" rows="5"
placeholder="Aqua&#10;Glycerin&#10;Niacinamide" placeholder={m["productForm_inciPlaceholder"]()}
class={textareaClass} class={textareaClass}
bind:value={inciText} bind:value={inciText}
></textarea> ></textarea>
@ -642,19 +621,27 @@
<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">
<Label>{m["productForm_activeIngredients"]()}</Label> <button
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} />
{#each actives as active, i} {#if activesPanelOpen}
{#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="e.g. Niacinamide" placeholder={m["productForm_activeNamePlaceholder"]()}
bind:value={active.name} bind:value={active.name}
/> />
</div> </div>
@ -664,7 +651,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"
>✕</Button> ><X class="size-3.5" /></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">
@ -674,7 +661,7 @@
min="0" min="0"
max="100" max="100"
step="0.01" step="0.01"
placeholder="e.g. 5" placeholder={m["productForm_activePercentPlaceholder"]()}
bind:value={active.percent} bind:value={active.percent}
/> />
</div> </div>
@ -701,7 +688,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} {#each ingFunctions as fn (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"
@ -720,367 +707,65 @@
{#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}
</div>
</CardContent>
</Card>
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each effectFields as field}
{@const key = field.key as keyof typeof effectValues}
<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>
<!-- ── Interactions ───────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>{m["productForm_interactions"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="synergizes_with">{m["productForm_synergizesWith"]()}</Label>
<textarea
id="synergizes_with"
name="synergizes_with"
rows="3"
placeholder="Ceramides&#10;Niacinamide&#10;Retinoids"
class={textareaClass}
bind:value={synergizesWithText}
></textarea>
</div>
<div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_incompatibleWith"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{m["productForm_addIncompatibility"]()}
</Button>
</div>
<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} {/if}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- ── Context rules ──────────────────────────────────────────────────────── --> <!-- ── Effect profile ─────────────────────────────────────────────────────── -->
<Card> {#await import('$lib/components/product-form/ProductFormAssessmentSection.svelte') then mod}
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader> {@const AssessmentSection = mod.default}
<CardContent> <AssessmentSection
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> visible={editSection === 'assessment'}
<div class="space-y-2"> {selectClass}
<Label>{m["productForm_ctxAfterShaving"]()}</Label> {effectFields}
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} /> bind:effectValues
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}> {tristate}
<SelectTrigger>{tristateLabel(ctxAfterShaving)}</SelectTrigger> bind:ctxAfterShaving
<SelectContent> bind:ctxAfterAcids
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each} bind:ctxAfterRetinoids
</SelectContent> bind:ctxCompromisedBarrier
</Select> bind:ctxLowUvOnly
</div> bind:fragranceFree
bind:essentialOilsFree
<div class="space-y-2"> bind:alcoholDenatFree
<Label>{m["productForm_ctxAfterAcids"]()}</Label> bind:pregnancySafe
<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"
/> />
{m["productForm_isMedication"]()} {/await}
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm"> {#await import('$lib/components/product-form/ProductFormDetailsSection.svelte') then mod}
<input type="hidden" name="is_tool" value={String(isTool)} /> {@const DetailsSection = mod.default}
<input <DetailsSection
type="checkbox" visible={editSection === 'details'}
checked={isTool} {textareaClass}
onchange={() => (isTool = !isTool)} bind:priceAmount
class="rounded border-input" bind:priceCurrency
bind:sizeMl
bind:fullWeightG
bind:emptyWeightG
bind:paoMonths
bind:phMin
bind:phMax
bind:usageNotes
bind:minIntervalHours
bind:maxFrequencyPerWeek
bind:needleLengthMm
bind:isMedication
bind:isTool
{computedPriceLabel}
{computedPricePerUseLabel}
{computedPriceTierLabel}
/> />
{m["productForm_isTool"]()} {/await}
</label>
</div>
<div class="space-y-2"> {#await import('$lib/components/product-form/ProductFormNotesSection.svelte') then mod}
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label> {@const NotesSection = mod.default}
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" bind:value={needleLengthMm} /> <NotesSection
</div> visible={editSection === 'notes'}
</CardContent> {selectClass}
</Card> {textareaClass}
{tristate}
<!-- ── Personal notes ─────────────────────────────────────────────────────── --> bind:personalRepurchaseIntent
<Card> bind:personalToleranceNotes
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader> />
<CardContent class="space-y-4"> {/await}
<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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,16 @@
import { type VariantProps, tv } from "tailwind-variants"; 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-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-semibold whitespace-nowrap tracking-[0.08em] uppercase transition-[color,box-shadow,border-color,background-color] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent", "bg-[var(--page-accent)] text-white [a&]:hover:brightness-95 border-transparent",
secondary: 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: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", outline: "border-border bg-transparent text-foreground [a&]:hover:border-[color:var(--page-accent)] [a&]:hover:bg-[var(--page-accent-soft)]",
}, },
}, },
defaultVariants: { 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 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md border text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs", default: "border-transparent bg-[var(--page-accent)] text-white shadow-sm hover:brightness-95",
destructive: destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs", "border-transparent bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-white shadow-sm",
outline: outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs", "border-border bg-card text-card-foreground shadow-sm hover:border-[color:var(--page-accent)] hover:bg-[var(--page-accent-soft)]",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs", secondary: "border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-[var(--page-accent-soft)]",
link: "text-primary underline-offset-4 hover:underline", link: "border-transparent px-0 text-[var(--page-accent)] 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", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-[0_18px_36px_-32px_hsl(210_24%_15%_/_0.55)]",
className 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] outline-none disabled:cursor-not-allowed disabled:opacity-50", "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "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] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "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,7 +33,6 @@ 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"
@ -43,6 +42,7 @@ 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,12 +113,6 @@ 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;
@ -154,7 +148,11 @@ 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;
@ -172,8 +170,6 @@ 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,19 +4,31 @@
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: '🏠' }, { href: resolve('/'), label: m.nav_dashboard(), icon: House },
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' }, { href: resolve('/products'), label: m.nav_products(), icon: Package },
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' }, { href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' }, { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' }, { href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' }, { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical },
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' } { href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }
]); ]);
function isActive(href: string) { function isActive(href: string) {
@ -29,24 +41,33 @@
); );
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="flex min-h-screen flex-col bg-background md:flex-row"> <div class="app-shell {domainClass}">
<!-- Mobile header --> <!-- Mobile header -->
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden"> <header class="app-mobile-header md:hidden">
<div> <div>
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span> <span class="app-mobile-title">{m["nav_appName"]()}</span>
</div> </div>
<button <button
type="button" type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)} onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground" class="app-icon-button"
aria-label="Toggle menu" aria-label={m.common_toggleMenu()}
> >
{#if mobileMenuOpen} {#if mobileMenuOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <X class="size-[18px]" />
{:else} {:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg> <Menu class="size-[18px]" />
{/if} {/if}
</button> </button>
</header> </header>
@ -60,16 +81,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 of backdrop) --> <!-- Drawer (same z-50 but later in DOM, on top) -->
<nav <nav
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden" class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden app-sidebar"
> >
<div class="mb-8 px-3"> <div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1> <h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p> <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} {#each navItems as item (item.href)}
<li> <li>
<a <a
href={item.href} href={item.href}
@ -79,7 +100,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'}"
> >
<span class="text-base">{item.icon}</span> <item.icon class="size-4 shrink-0" />
{item.label} {item.label}
</a> </a>
</li> </li>
@ -92,13 +113,13 @@
{/if} {/if}
<!-- Desktop Sidebar --> <!-- Desktop Sidebar -->
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex"> <nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
<div class="mb-8 px-3"> <div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1> <h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p> <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} {#each navItems as item (item.href)}
<li> <li>
<a <a
href={item.href} href={item.href}
@ -107,7 +128,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'}"
> >
<span class="text-base">{item.icon}</span> <item.icon class="size-4 shrink-0" />
{item.label} {item.label}
</a> </a>
</li> </li>
@ -119,7 +140,7 @@
</nav> </nav>
<!-- Main content --> <!-- Main content -->
<main class="flex-1 overflow-auto p-4 md:p-8"> <main class="app-main">
{@render children()} {@render children()}
</main> </main>
</div> </div>

View file

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

View file

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

View file

@ -1,13 +1,14 @@
<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 { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -15,12 +16,12 @@
let showForm = $state(false); let showForm = $state(false);
let kind = $state('supplement'); let kind = $state('supplement');
const kindColors: Record<string, string> = { const kindPills: Record<string, string> = {
prescription: 'bg-purple-100 text-purple-800', prescription: 'health-kind-pill health-kind-pill--prescription',
otc: 'bg-blue-100 text-blue-800', otc: 'health-kind-pill health-kind-pill--otc',
supplement: 'bg-green-100 text-green-800', supplement: 'health-kind-pill health-kind-pill--supplement',
herbal: 'bg-emerald-100 text-emerald-800', herbal: 'health-kind-pill health-kind-pill--herbal',
other: 'bg-gray-100 text-gray-700' other: 'health-kind-pill health-kind-pill--other'
}; };
const kindLabels: Record<string, () => string> = { const kindLabels: Record<string, () => string> = {
@ -30,44 +31,43 @@
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="space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center justify-between"> <section class="editorial-hero reveal-1">
<div> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="text-2xl font-bold tracking-tight">{m.medications_title()}</h2> <h2 class="editorial-title">{m.medications_title()}</h2>
<p class="text-muted-foreground">{m.medications_count({ count: data.medications.length })}</p> <p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
</div> <div class="editorial-toolbar">
<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="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
{#if form?.created} {#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.medications_added()}</div> <div class="editorial-alert editorial-alert--success">{m.medications_added()}</div>
{/if} {/if}
{#if showForm} {#if showForm}
<Card> <FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
<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="space-y-1 col-span-2"> <div class="col-span-2">
<Label>{m.medications_kind()}</Label> <SimpleSelect
<input type="hidden" name="kind" value={kind} /> id="kind"
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}> name="kind"
<SelectTrigger>{kindLabels[kind]?.() ?? kind}</SelectTrigger> label={m.medications_kind()}
<SelectContent> options={kindOptions}
{#each kinds as k (k)} bind:value={kind}
<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,16 +85,15 @@
<Button type="submit">{m.common_add()}</Button> <Button type="submit">{m.common_add()}</Button>
</div> </div>
</form> </form>
</CardContent> </FormSectionCard>
</Card>
{/if} {/if}
<div class="space-y-3"> <div class="editorial-panel reveal-2 space-y-3">
{#each data.medications as med (med.record_id)} {#each data.medications as med (med.record_id)}
<div class="rounded-md border border-border px-4 py-3"> <div class="health-entry-row">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {kindColors[med.kind] ?? ''}"> <span class={kindPills[med.kind] ?? 'health-kind-pill'}>
{kindLabels[med.kind]?.() ?? med.kind} {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,6 +6,8 @@
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,
@ -18,7 +20,11 @@
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',
@ -29,14 +35,59 @@
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) => {
const bc = a.brand.localeCompare(b.brand); let cmp = 0;
return bc !== 0 ? bc : a.name.localeCompare(b.name); if (sortKey === 'brand') cmp = compareText(a.brand, b.brand);
if (sortKey === 'name') cmp = compareText(a.name, b.name);
if (sortKey === 'time') cmp = compareText(a.recommended_time, b.recommended_time);
if (sortKey === 'price') cmp = comparePrice(getPricePerUse(a), getPricePerUse(b));
if (cmp === 0) {
const byBrand = compareText(a.brand, b.brand);
cmp = byBrand !== 0 ? byBrand : compareText(a.name, b.name);
}
return sortDirection === 'asc' ? cmp : -cmp;
}); });
const map = new SvelteMap<string, Product[]>(); const map = new SvelteMap<string, Product[]>();
@ -55,23 +106,46 @@
})()); })());
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="space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center justify-between"> <section class="editorial-hero reveal-1">
<div> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2> <h2 class="editorial-title">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p> <p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p>
</div> <div class="editorial-toolbar">
<div class="flex gap-2"> <Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
<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>
</div> </section>
<div class="flex flex-wrap gap-1"> <div class="editorial-panel reveal-2">
<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'}
@ -83,8 +157,37 @@
{/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="hidden rounded-md border border-border md:block"> <div class="products-table-shell hidden md:block reveal-2">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -92,26 +195,27 @@
<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={4} class="text-center text-muted-foreground py-8"> <TableCell colspan={5} 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="bg-muted/30 hover:bg-muted/30"> <TableRow class="products-category-row">
<TableCell colspan={4} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide"> <TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{category.replace(/_/g, ' ')} {formatCategory(category)}
</TableCell> </TableCell>
</TableRow> </TableRow>
{#each products as product (product.id)} {#each products as product (product.id)}
<TableRow class="cursor-pointer hover:bg-muted/50"> <TableRow class={`cursor-pointer ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/25 text-muted-foreground hover:bg-muted/35'}`}>
<TableCell> <TableCell class="max-w-[32rem] align-top whitespace-normal">
<a href="/products/{product.id}" class="font-medium hover:underline"> <a href={resolve(`/products/${product.id}`)} title={product.name} class="block break-words line-clamp-2 font-medium hover:underline">
{product.name} {product.name}
</a> </a>
</TableCell> </TableCell>
@ -127,6 +231,12 @@
</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}
@ -136,23 +246,27 @@
</div> </div>
<!-- Mobile: cards --> <!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden"> <div class="flex flex-col gap-3 md:hidden reveal-3">
{#if totalCount === 0} {#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="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div class="products-section-title">
{category.replace(/_/g, ' ')} {formatCategory(category)}
</div> </div>
{#each products as product (product.id)} {#each products as product (product.id)}
<a <a
href="/products/{product.id}" href={resolve(`/products/${product.id}`)}
class="block rounded-lg border border-border p-4 hover:bg-muted/50" class={`products-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div> <div class="min-w-0">
<p class="font-medium">{product.name}</p> <p class="break-words line-clamp-2 font-medium" title={product.name}>{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p> <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,17 +119,19 @@ export const actions: Actions = {
}; };
// Optional strings // Optional strings
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) { for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) {
const v = parseOptionalString(form.get(field) as string | null); 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', 'price_tier']) { for (const field of ['texture', 'absorption_speed']) {
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;
@ -166,23 +168,6 @@ 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,10 +5,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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte'; import ProductForm from '$lib/components/ProductForm.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -16,37 +17,115 @@
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="max-w-2xl space-y-6"> <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> <Button
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> type="submit"
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2> form="product-edit-form"
disabled={activeTab !== 'edit' || !isEditDirty}
size="sm"
>
<Save class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span>
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">
<Trash2 class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_deleteProduct"]()}</span>
</Button>
</form>
</div>
<div class="editorial-page space-y-4 pb-20 md:pb-0">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="break-words editorial-title">{product.name}</h2>
<div class="products-meta-strip">
<span class="font-medium text-foreground">{product.brand}</span>
<span></span>
<span class="uppercase">{product.recommended_time}</span>
<span></span>
<span>{product.category.replace(/_/g, ' ')}</span>
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
</div> </div>
</section>
{#if form?.error} {#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
{#if form?.success} {#if form?.success}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div> <div class="editorial-alert editorial-alert--success">{m.common_saved()}</div>
{/if} {/if}
<!-- Edit form --> <Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
<form method="POST" action="?/update" use:enhance class="space-y-6"> <TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
<ProductForm {product} /> <TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
<Boxes class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m.inventory_title({ count: product.inventory.length })}</span>
</TabsTrigger>
<TabsTrigger value="edit" class="shrink-0 px-3" title={m.common_edit()}>
<Pencil class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m.common_edit()}</span>
</TabsTrigger>
</TabsList>
<div class="flex gap-3"> <TabsContent value="inventory" class="space-y-4 pt-2">
<Button type="submit">{m["products_saveChanges"]()}</Button>
</div>
</form>
<Separator />
<!-- Inventory -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3> <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"]()}
@ -54,20 +133,23 @@
</div> </div>
{#if form?.inventoryAdded} {#if form?.inventoryAdded}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageAdded"]()}</div> <div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div>
{/if} {/if}
{#if form?.inventoryUpdated} {#if form?.inventoryUpdated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageUpdated"]()}</div> <div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div>
{/if} {/if}
{#if form?.inventoryDeleted} {#if form?.inventoryDeleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageDeleted"]()}</div> <div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
{/if} {/if}
{#if showInventoryForm} {#if showInventoryForm}
<Card> <Card>
<CardContent class="pt-4"> <CardHeader class="pb-2">
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4"> <CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
<div class="col-span-2 flex items-center gap-2"> </CardHeader>
<CardContent>
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" /> <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>
@ -80,20 +162,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="expiry_date">{m["inventory_expiryDate"]()}</Label> <Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="expiry_date" name="expiry_date" type="date" /> <Input id="add_expiry_date" name="expiry_date" type="date" />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="current_weight_g">{m["inventory_currentWeight"]()}</Label> <Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" /> <Input id="add_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="notes">{m.inventory_notes()}</Label> <Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="notes" name="notes" /> <Input id="add_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>
@ -107,8 +189,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 items-center justify-between px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="flex flex-wrap items-center gap-2"> <div class="flex min-w-0 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>
@ -149,7 +231,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">×</Button> <Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
</form> </form>
</div> </div>
</div> </div>
@ -165,10 +247,10 @@
if (result.type === 'success') editingInventoryId = null; if (result.type === 'success') editingInventoryId = null;
}; };
}} }}
class="grid grid-cols-2 gap-4" class="grid grid-cols-1 gap-4 sm:grid-cols-2"
> >
<input type="hidden" name="inventory_id" value={pkg.id} /> <input type="hidden" name="inventory_id" value={pkg.id} />
<div class="col-span-2 flex items-center gap-2"> <div class="sm: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}"
@ -181,49 +263,23 @@
</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 <Input id="edit_opened_at_{pkg.id}" name="opened_at" type="date" value={pkg.opened_at?.slice(0, 10) ?? ''} />
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 <Input id="edit_finished_at_{pkg.id}" name="finished_at" type="date" value={pkg.finished_at?.slice(0, 10) ?? ''} />
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 <Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
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 <Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
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 <Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
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>
@ -231,12 +287,7 @@
</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 <Button type="button" variant="ghost" size="sm" onclick={() => (editingInventoryId = null)}>{m.common_cancel()}</Button>
type="button"
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = null)}
>{m.common_cancel()}</Button>
</div> </div>
</form> </form>
</div> </div>
@ -248,20 +299,48 @@
<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>
<Separator /> <TabsContent value="edit" class="space-y-4 pt-2">
<Card>
<!-- Danger zone --> <CardHeader class="pb-2">
<div> <div class="flex items-center justify-between gap-2">
<form <CardTitle class="text-base">{m.common_edit()}</CardTitle>
method="POST" <Button type="button" variant="outline" size="sm" onclick={() => productFormRef?.openAiModal()}>
action="?/delete" <Sparkles class="size-4" />
use:enhance {m["productForm_aiPrefill"]()}
onsubmit={(e) => { </Button>
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
</form>
</div> </div>
</CardHeader>
<CardContent>
<form
id="product-edit-form"
method="POST"
action="?/update"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
isEditDirty = false;
editSaveVersion += 1;
}
};
}}
class="space-y-6"
>
<ProductForm
bind:this={productFormRef}
{product}
onDirtyChange={(dirty) => (isEditDirty = dirty)}
saveVersion={editSaveVersion}
showAiTrigger={false}
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
computedPricePerUseLabel={formatPricePerUse(getPricePerUse())}
computedPriceTierLabel={formatTier(product.price_tier)}
/>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div> </div>

View file

@ -107,17 +107,20 @@ export const actions: Actions = {
}; };
// Optional strings // Optional strings
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) { for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) {
const v = parseOptionalString(form.get(field) as string | null); 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', 'price_tier']) { for (const field of ['texture', 'absorption_speed']) {
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;
@ -167,19 +170,6 @@ 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,6 +4,7 @@
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();
@ -19,19 +20,20 @@
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center gap-4"> <section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> <a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
</div> <h2 class="editorial-title">{m["products_newTitle"]()}</h2>
</section>
{#if form?.error} {#if form?.error}
<div bind:this={errorEl} class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div bind:this={errorEl} class="editorial-alert editorial-alert--error">
{form.error} {form.error}
</div> </div>
{/if} {/if}
<form method="POST" use:enhance class="space-y-6"> <form method="POST" use:enhance class="editorial-panel reveal-2 space-y-6 p-4">
<ProductForm /> <ProductForm />
<div class="flex gap-3 pb-6"> <div class="flex gap-3 pb-6">

View file

@ -6,6 +6,7 @@
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('');
@ -31,17 +32,18 @@
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center gap-4"> <section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> <a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
</div> <h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
</section>
{#if errorMsg} {#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div> <div class="editorial-alert editorial-alert--error">{errorMsg}</div>
{/if} {/if}
<Card> <Card class="reveal-2">
<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">
@ -53,7 +55,7 @@
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span> <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}
{m["products_suggestBtn"]()} <Sparkles class="size-4" /> {m["products_suggestBtn"]()}
{/if} {/if}
</Button> </Button>
</form> </form>
@ -62,14 +64,14 @@
{#if suggestions && suggestions.length > 0} {#if suggestions && suggestions.length > 0}
{#if reasoning} {#if reasoning}
<Card class="border-muted bg-muted/30"> <Card class="border-muted bg-muted/30 reveal-3">
<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"> <div class="space-y-4 reveal-3">
<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,28 +22,27 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="space-y-6"> <div class="editorial-page space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <section class="editorial-hero reveal-1">
<div> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2> <h2 class="editorial-title">{m.routines_title()}</h2>
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p> <p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
</div> <div class="editorial-toolbar">
<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>
</div> </section>
{#if sortedDates.length} {#if sortedDates.length}
<div class="space-y-4"> <div class="editorial-panel reveal-2 space-y-4">
{#each sortedDates as date} {#each sortedDates as date (date)}
<div> <div>
<h3 class="text-sm font-semibold text-muted-foreground mb-2">{date}</h3> <h3 class="products-section-title mb-2">{date}</h3>
<div class="space-y-1"> <div class="space-y-1">
{#each byDate[date] as routine} {#each byDate[date] as routine (routine.id)}
<a <a
href="/routines/{routine.id}" href={resolve(`/routines/${routine.id}`)}
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors" class="routine-ledger-row"
> >
<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'}>
@ -61,6 +60,8 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="text-sm text-muted-foreground">{m["routines_noRoutines"]()}</p> <div class="editorial-panel reveal-2">
<p class="empty-copy">{m["routines_noRoutines"]()}</p>
</div>
{/if} {/if}
</div> </div>

View file

@ -1,5 +1,6 @@
<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';
@ -10,16 +11,11 @@
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 { import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
Select, import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
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);
@ -136,21 +132,36 @@
.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="max-w-2xl space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center gap-4"> <section class="editorial-panel reveal-1 space-y-3">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<div class="flex items-center gap-3">
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}> <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="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
{#if routine.notes} {#if routine.notes}
@ -158,7 +169,7 @@
{/if} {/if}
<!-- Steps --> <!-- Steps -->
<div class="space-y-3"> <div class="editorial-panel reveal-2 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)}>
@ -171,29 +182,14 @@
<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">
<div class="space-y-1"> <GroupedSelect
<Label>{m.routines_product()}</Label> id="new_step_product"
<input type="hidden" name="product_id" value={selectedProductId} /> name="product_id"
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}> label={m.routines_product()}
<SelectTrigger> groups={groupedProductOptions}
{#if selectedProductId} placeholder={m["routines_selectProduct"]()}
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()} bind:value={selectedProductId}
{: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">
@ -225,32 +221,14 @@
<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 -->
<div class="space-y-1"> <GroupedSelect
<Label>{m.routines_product()}</Label> id={`edit_step_product_${step.id}`}
<Select label={m.routines_product()}
type="single" groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
value={editDraft.product_id ?? ''} value={editDraft.product_id ?? ''}
onValueChange={(v) => (editDraft.product_id = v || undefined)} onChange={(value) => (editDraft.product_id = value || 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>
@ -271,30 +249,21 @@
</div> </div>
{:else} {:else}
<!-- Action step: change action_type / notes --> <!-- Action step: change action_type / notes -->
<div class="space-y-1"> <SimpleSelect
<Label>Action</Label> id={`edit_step_action_${step.id}`}
<Select label={m["routines_action"]()}
type="single" options={groomingActionOptions}
placeholder={m["routines_selectAction"]()}
value={editDraft.action_type ?? ''} value={editDraft.action_type ?? ''}
onValueChange={(v) => onChange={(value) =>
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)} (editDraft.action_type = (value || 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"> <div class="space-y-1">
<Label>Notes</Label> <Label>{m.routines_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="optional notes" placeholder={m["routines_actionNotesPlaceholder"]()}
/> />
</div> </div>
{/if} {/if}
@ -318,8 +287,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="drag to reorder" aria-label={m.common_dragToReorder()}
>⋮⋮</span> ><GripVertical class="size-4" /></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}
@ -340,8 +309,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="edit step" aria-label={m.common_editStep()}
></Button> ><Pencil class="size-4" /></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
@ -349,7 +318,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"
>×</Button> ><X class="size-4" /></Button>
</form> </form>
</div> </div>
{/if} {/if}
@ -361,7 +330,7 @@
{/if} {/if}
</div> </div>
<Separator /> <Separator class="opacity-50" />
<form <form
method="POST" method="POST"

View file

@ -1,5 +1,6 @@
<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';
@ -8,6 +9,7 @@
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();
@ -45,28 +47,31 @@
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["grooming_backToRoutines"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2> <p class="editorial-kicker mt-2">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title mt-1 text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
</div> </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="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
{#if form?.created} {#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryAdded"]()}</div> <div class="editorial-alert editorial-alert--success">{m["grooming_entryAdded"]()}</div>
{/if} {/if}
{#if form?.updated} {#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryUpdated"]()}</div> <div class="editorial-alert editorial-alert--success">{m["grooming_entryUpdated"]()}</div>
{/if} {/if}
{#if form?.deleted} {#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryDeleted"]()}</div> <div class="editorial-alert editorial-alert--success">{m["grooming_entryDeleted"]()}</div>
{/if} {/if}
<!-- Add form --> <!-- Add form -->

View file

@ -1,49 +1,51 @@
<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 { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
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="max-w-md space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center gap-4"> <section class="editorial-panel reveal-1 space-y-3">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
</div> <h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
</section>
{#if form?.error} {#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
<Card> <FormSectionCard title={m["routines_detailsTitle"]()} className="reveal-2">
<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>
<div class="space-y-2"> <SimpleSelect
<Label>{m["routines_amOrPm"]()}</Label> id="part_of_day"
<input type="hidden" name="part_of_day" value={partOfDay} /> name="part_of_day"
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}> label={m["routines_amOrPm"]()}
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger> options={partOfDayOptions}
<SelectContent> bind:value={partOfDay}
<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>
@ -52,9 +54,8 @@
<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="/routines">{m.common_cancel()}</Button> <Button variant="outline" href={resolve('/routines')}>{m.common_cancel()}</Button>
</div> </div>
</form> </form>
</CardContent> </FormSectionCard>
</Card>
</div> </div>

View file

@ -7,11 +7,15 @@
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 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 { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { baseTextareaClass } from '$lib/components/forms/form-classes';
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { 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();
@ -33,6 +37,13 @@
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];
@ -103,17 +114,18 @@
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center gap-4"> <section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
</div> <h2 class="editorial-title">{m.suggest_title()}</h2>
</section>
{#if errorMsg} {#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div> <div class="editorial-alert editorial-alert--error">{errorMsg}</div>
{/if} {/if}
<Tabs value="single"> <Tabs value="single" class="reveal-2 editorial-tabs">
<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>
@ -121,26 +133,20 @@
<!-- ── Single tab ─────────────────────────────────────────────────── --> <!-- ── Single tab ─────────────────────────────────────────────────── -->
<TabsContent value="single" class="space-y-6 pt-4"> <TabsContent value="single" class="space-y-6 pt-4">
<Card> <FormSectionCard title={m["suggest_singleParams"]()}>
<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>
<div class="space-y-2"> <SimpleSelect
<Label>{m["suggest_timeOfDay"]()}</Label> id="single_part_of_day"
<input type="hidden" name="part_of_day" value={partOfDay} /> name="part_of_day"
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}> label={m["suggest_timeOfDay"]()}
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger> options={partOfDayOptions}
<SelectContent> bind:value={partOfDay}
<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">
@ -150,35 +156,23 @@
name="notes" name="notes"
rows="2" rows="2"
placeholder={m["suggest_contextPlaceholder"]()} placeholder={m["suggest_contextPlaceholder"]()}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none" class={textareaClass}
></textarea> ></textarea>
</div> </div>
{#if partOfDay === 'am'} {#if partOfDay === 'am'}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2"> <HintCheckbox
<input
id="single_leaving_home" id="single_leaving_home"
name="leaving_home" name="leaving_home"
type="checkbox" label={m["suggest_leavingHomeLabel"]()}
class="mt-0.5 h-4 w-4 rounded border-input" hint={m["suggest_leavingHomeHint"]()}
/> />
<div class="space-y-0.5">
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
</div>
</div>
{/if} {/if}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2"> <HintCheckbox
<input
id="single_include_minoxidil_beard" id="single_include_minoxidil_beard"
name="include_minoxidil_beard" name="include_minoxidil_beard"
type="checkbox" label={m["suggest_minoxidilToggleLabel"]()}
class="mt-0.5 h-4 w-4 rounded border-input" hint={m["suggest_minoxidilToggleHint"]()}
/> />
<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}
@ -189,8 +183,7 @@
{/if} {/if}
</Button> </Button>
</form> </form>
</CardContent> </FormSectionCard>
</Card>
{#if suggestion} {#if suggestion}
<div class="space-y-4"> <div class="space-y-4">
@ -271,9 +264,7 @@
<!-- ── Batch tab ──────────────────────────────────────────────────── --> <!-- ── Batch tab ──────────────────────────────────────────────────── -->
<TabsContent value="batch" class="space-y-6 pt-4"> <TabsContent value="batch" class="space-y-6 pt-4">
<Card> <FormSectionCard title={m["suggest_batchRange"]()}>
<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">
@ -293,33 +284,21 @@
name="notes" name="notes"
rows="2" rows="2"
placeholder={m["suggest_batchContextPlaceholder"]()} placeholder={m["suggest_batchContextPlaceholder"]()}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none" class={textareaClass}
></textarea> ></textarea>
</div> </div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2"> <HintCheckbox
<input
id="batch_include_minoxidil_beard" id="batch_include_minoxidil_beard"
name="include_minoxidil_beard" name="include_minoxidil_beard"
type="checkbox" label={m["suggest_minoxidilToggleLabel"]()}
class="mt-0.5 h-4 w-4 rounded border-input" hint={m["suggest_minoxidilToggleHint"]()}
/> />
<div class="space-y-0.5"> <HintCheckbox
<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"
type="checkbox" label={m["suggest_minimizeProductsLabel"]()}
class="mt-0.5 h-4 w-4 rounded border-input" hint={m["suggest_minimizeProductsHint"]()}
/> />
<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}
@ -330,8 +309,7 @@
{/if} {/if}
</Button> </Button>
</form> </form>
</CardContent> </FormSectionCard>
</Card>
{#if batch} {#if batch}
<div class="space-y-4"> <div class="space-y-4">
@ -359,9 +337,13 @@
<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">
AM {day.am_steps.length} {m["suggest_amSteps"]()} · PM {day.pm_steps.length} {m["suggest_pmSteps"]()} {m.common_am()} {day.am_steps.length} {m["suggest_amSteps"]()} · {m.common_pm()} {day.pm_steps.length} {m["suggest_pmSteps"]()}
</span> </span>
<span class="text-muted-foreground">{isOpen ? '▲' : '▼'}</span> {#if isOpen}
<ChevronUp class="size-4 text-muted-foreground" />
{:else}
<ChevronDown class="size-4 text-muted-foreground" />
{/if}
</div> </div>
</button> </button>
@ -374,7 +356,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>AM</Badge> <Badge>{m.common_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}
@ -399,7 +381,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">PM</Badge> <Badge variant="secondary">{m.common_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 { Input } from '$lib/components/ui/input'; import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
import { Label } from '$lib/components/ui/label'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Sparkles, Pencil, X } from 'lucide-svelte';
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 stateColors: Record<string, string> = { const statePills: Record<string, string> = {
excellent: 'bg-green-100 text-green-800', excellent: 'state-pill state-pill--excellent',
good: 'bg-blue-100 text-blue-800', good: 'state-pill state-pill--good',
fair: 'bg-yellow-100 text-yellow-800', fair: 'state-pill state-pill--fair',
poor: 'bg-red-100 text-red-800' poor: 'state-pill state-pill--poor'
}; };
const stateLabels: Record<string, () => string> = { const stateLabels: Record<string, () => string> = {
@ -102,12 +102,17 @@
} }
// AI photo analysis state // AI photo analysis state
let aiPanelOpen = $state(false); let aiModalOpen = $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))
); );
@ -137,180 +142,219 @@
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;
aiPanelOpen = false; aiModalOpen = 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="space-y-6"> <div class="editorial-page space-y-4">
<div class="flex items-center justify-between"> <section class="editorial-hero reveal-1">
<div> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2> <h2 class="editorial-title">{m.skin_title()}</h2>
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p> <p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p>
</div> <div class="editorial-toolbar">
{#if showForm}
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
<Sparkles class="size-4" />
{m["skin_aiAnalysisTitle"]()}
</Button>
{/if}
<Button variant="outline" onclick={() => (showForm = !showForm)}> <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="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
{#if form?.created} {#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotAdded"]()}</div> <div class="editorial-alert editorial-alert--success">{m["skin_snapshotAdded"]()}</div>
{/if} {/if}
{#if form?.updated} {#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotUpdated"]()}</div> <div class="editorial-alert editorial-alert--success">{m["skin_snapshotUpdated"]()}</div>
{/if} {/if}
{#if form?.deleted} {#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotDeleted"]()}</div> <div class="editorial-alert editorial-alert--success">{m["skin_snapshotDeleted"]()}</div>
{/if} {/if}
{#if showForm} {#if showForm}
<!-- AI photo analysis card --> {#if aiModalOpen}
<Card>
<CardHeader>
<button <button
type="button" type="button"
class="flex w-full items-center justify-between text-left" class="fixed inset-0 z-50 bg-black/50"
onclick={() => (aiPanelOpen = !aiPanelOpen)} onclick={closeAiModal}
> aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle> <CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span> <Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
</button> <X class="size-4" />
</Button>
</div>
</CardHeader> </CardHeader>
{#if aiPanelOpen} <CardContent class="space-y-3 overflow-y-auto p-4">
<CardContent class="space-y-3"> <p class="text-sm text-muted-foreground">{m["skin_aiUploadText"]()}</p>
<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 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"
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 object-cover border" /> <img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
{/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}
<Button <div class="flex justify-end gap-2">
type="button" <Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
onclick={analyzePhotos} <Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
disabled={aiLoading || !selectedFiles.length}
>
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()} {aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
</Button> </Button>
</div>
</CardContent> </CardContent>
{/if}
</Card> </Card>
</div>
{/if}
<!-- New snapshot form --> <!-- New snapshot form -->
<Card> <Card class="reveal-2">
<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">
<div class="space-y-1"> <LabeledInputField
<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"
bind:value={snapshotDate}
required required
bind:value={snapshotDate}
/>
<SimpleSelect
id="overall_state"
name="overall_state"
label={m["skin_overallState"]()}
options={overallStateOptions}
placeholder={m.common_select()}
bind:value={overallState}
/>
<SimpleSelect
id="texture"
name="texture"
label={m.skin_texture()}
options={textureOptions}
placeholder={m.common_select()}
bind:value={texture}
/>
<SimpleSelect
id="skin_type"
name="skin_type"
label={m["skin_skinType"]()}
options={skinTypeOptions}
placeholder={m.common_select()}
bind:value={skinType}
/>
<SimpleSelect
id="barrier_state"
name="barrier_state"
label={m["skin_barrierState"]()}
options={barrierOptions}
placeholder={m.common_select()}
bind:value={barrierState}
/>
<LabeledInputField
id="hydration_level"
name="hydration_level"
label={m.skin_hydration()}
type="number"
min="1"
max="5"
bind:value={hydrationLevel}
/>
<LabeledInputField
id="sensitivity_level"
name="sensitivity_level"
label={m.skin_sensitivity()}
type="number"
min="1"
max="5"
bind:value={sensitivityLevel}
/>
<LabeledInputField
id="sebum_tzone"
name="sebum_tzone"
label={m["skin_sebumTzone"]()}
type="number"
min="1"
max="5"
bind:value={sebumTzone}
/>
<LabeledInputField
id="sebum_cheeks"
name="sebum_cheeks"
label={m["skin_sebumCheeks"]()}
type="number"
min="1"
max="5"
bind:value={sebumCheeks}
/>
<LabeledInputField
id="active_concerns"
name="active_concerns"
label={m["skin_activeConcerns"]()}
placeholder={m["skin_activeConcernsPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={activeConcernsRaw}
/>
<LabeledInputField
id="priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={prioritiesRaw}
/>
<LabeledInputField
id="notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={notes}
/> />
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={overallState} />
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={skinType} />
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={barrierState} />
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="hydration_level">{m.skin_hydration()}</Label>
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
</div>
<div class="space-y-1">
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
</div>
<div class="space-y-1">
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="priorities">{m["skin_priorities"]()}</Label>
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">{m.skin_notes()}</Label>
<Input id="notes" name="notes" bind:value={notes} />
</div>
<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>
@ -319,7 +363,7 @@
</Card> </Card>
{/if} {/if}
<div class="space-y-4"> <div class="space-y-4 reveal-2">
{#each sortedSnapshots as snap (snap.id)} {#each sortedSnapshots as snap (snap.id)}
<Card> <Card>
<CardContent class="pt-4"> <CardContent class="pt-4">
@ -335,86 +379,105 @@
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} />
<div class="space-y-1"> <LabeledInputField
<Label for="edit_snapshot_date">{m.skin_date()}</Label> id="edit_snapshot_date"
<Input id="edit_snapshot_date" name="snapshot_date" type="date" bind:value={editSnapshotDate} required /> name="snapshot_date"
</div> label={m.skin_date()}
<div class="space-y-1"> type="date"
<Label>{m["skin_overallState"]()}</Label> required
<input type="hidden" name="overall_state" value={editOverallState} /> bind:value={editSnapshotDate}
<Select type="single" value={editOverallState} onValueChange={(v) => (editOverallState = v)}> />
<SelectTrigger>{editOverallState ? stateLabels[editOverallState]?.() ?? editOverallState : m.common_select()}</SelectTrigger> <SimpleSelect
<SelectContent> id="edit_overall_state"
{#each states as s (s)} name="overall_state"
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem> label={m["skin_overallState"]()}
{/each} options={overallStateOptions}
</SelectContent> placeholder={m.common_select()}
</Select> bind:value={editOverallState}
</div> />
<div class="space-y-1"> <SimpleSelect
<Label>{m.skin_texture()}</Label> id="edit_texture"
<input type="hidden" name="texture" value={editTexture} /> name="texture"
<Select type="single" value={editTexture} onValueChange={(v) => (editTexture = v)}> label={m.skin_texture()}
<SelectTrigger>{editTexture ? textureLabels[editTexture]?.() ?? editTexture : m.common_select()}</SelectTrigger> options={textureOptions}
<SelectContent> placeholder={m.common_select()}
{#each skinTextures as t (t)} bind:value={editTexture}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem> />
{/each} <SimpleSelect
</SelectContent> id="edit_skin_type"
</Select> name="skin_type"
</div> label={m["skin_skinType"]()}
<div class="space-y-1"> options={skinTypeOptions}
<Label>{m["skin_skinType"]()}</Label> placeholder={m.common_select()}
<input type="hidden" name="skin_type" value={editSkinType} /> bind:value={editSkinType}
<Select type="single" value={editSkinType} onValueChange={(v) => (editSkinType = v)}> />
<SelectTrigger>{editSkinType ? skinTypeLabels[editSkinType]?.() ?? editSkinType : m.common_select()}</SelectTrigger> <SimpleSelect
<SelectContent> id="edit_barrier_state"
{#each skinTypes as st (st)} name="barrier_state"
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem> label={m["skin_barrierState"]()}
{/each} options={barrierOptions}
</SelectContent> placeholder={m.common_select()}
</Select> bind:value={editBarrierState}
</div> />
<div class="space-y-1"> <LabeledInputField
<Label>{m["skin_barrierState"]()}</Label> id="edit_hydration_level"
<input type="hidden" name="barrier_state" value={editBarrierState} /> name="hydration_level"
<Select type="single" value={editBarrierState} onValueChange={(v) => (editBarrierState = v)}> label={m.skin_hydration()}
<SelectTrigger>{editBarrierState ? barrierLabels[editBarrierState]?.() ?? editBarrierState : m.common_select()}</SelectTrigger> type="number"
<SelectContent> min="1"
{#each barrierStates as b (b)} max="5"
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem> bind:value={editHydrationLevel}
{/each} />
</SelectContent> <LabeledInputField
</Select> id="edit_sensitivity_level"
</div> name="sensitivity_level"
<div class="space-y-1"> label={m.skin_sensitivity()}
<Label for="edit_hydration_level">{m.skin_hydration()}</Label> type="number"
<Input id="edit_hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={editHydrationLevel} /> min="1"
</div> max="5"
<div class="space-y-1"> bind:value={editSensitivityLevel}
<Label for="edit_sensitivity_level">{m.skin_sensitivity()}</Label> />
<Input id="edit_sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={editSensitivityLevel} /> <LabeledInputField
</div> id="edit_sebum_tzone"
<div class="space-y-1"> name="sebum_tzone"
<Label for="edit_sebum_tzone">{m["skin_sebumTzone"]()}</Label> label={m["skin_sebumTzone"]()}
<Input id="edit_sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={editSebumTzone} /> type="number"
</div> min="1"
<div class="space-y-1"> max="5"
<Label for="edit_sebum_cheeks">{m["skin_sebumCheeks"]()}</Label> bind:value={editSebumTzone}
<Input id="edit_sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={editSebumCheeks} /> />
</div> <LabeledInputField
<div class="space-y-1 col-span-2"> id="edit_sebum_cheeks"
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label> name="sebum_cheeks"
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} /> label={m["skin_sebumCheeks"]()}
</div> type="number"
<div class="space-y-1 col-span-2"> min="1"
<Label for="edit_priorities">{m["skin_priorities"]()}</Label> max="5"
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} /> bind:value={editSebumCheeks}
</div> />
<div class="space-y-1 col-span-2"> <LabeledInputField
<Label for="edit_notes">{m.skin_notes()}</Label> id="edit_active_concerns"
<Input id="edit_notes" name="notes" bind:value={editNotes} /> name="active_concerns"
</div> label={m["skin_activeConcerns"]()}
placeholder={m["skin_activeConcernsPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={editActiveConcernsRaw}
/>
<LabeledInputField
id="edit_priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={editPrioritiesRaw}
/>
<LabeledInputField
id="edit_notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={editNotes}
/>
<div class="col-span-2 flex gap-2"> <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>
@ -426,17 +489,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()}>✎</Button> <Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}><Pencil class="size-4" /></Button>
<form method="POST" action="?/delete" use:enhance> <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()}>×</Button> <Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}><X class="size-4" /></Button>
</form> </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="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}"> <span class={statePills[snap.overall_state] ?? 'state-pill'}>
{stateLabels[snap.overall_state]?.() ?? snap.overall_state} {stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span> </span>
{/if} {/if}

View file

@ -1,10 +1,23 @@
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()