Compare commits
5 commits
1d8a8eafb8
...
c869f88db2
| Author | SHA1 | Date | |
|---|---|---|---|
| c869f88db2 | |||
| 693c6a9626 | |||
| d4fbc1faf5 | |||
| 83ba4cc5c0 | |||
| c5ea38880c |
56 changed files with 4021 additions and 1797 deletions
|
|
@ -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")
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
0
backend/innercontext/services/__init__.py
Normal file
0
backend/innercontext/services/__init__.py
Normal file
77
backend/innercontext/services/fx.py
Normal file
77
backend/innercontext/services/fx.py
Normal 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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
177
backend/tests/test_products_pricing.py
Normal file
177
backend/tests/test_products_pricing.py
Normal 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)
|
||||||
|
|
@ -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
49
backend/uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
189
docs/frontend-design-cookbook.md
Normal file
189
docs/frontend-design-cookbook.md
Normal 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.
|
||||||
|
|
@ -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 (0–5)",
|
"productForm_effectProfile": "Effect profile (0–5)",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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 (0–5)",
|
"productForm_effectProfile": "Profil działania (0–5)",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
62
frontend/src/lib/components/ProductFormAiModal.svelte
Normal file
62
frontend/src/lib/components/ProductFormAiModal.svelte
Normal 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}
|
||||||
24
frontend/src/lib/components/forms/FormSectionCard.svelte
Normal file
24
frontend/src/lib/components/forms/FormSectionCard.svelte
Normal 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>
|
||||||
54
frontend/src/lib/components/forms/GroupedSelect.svelte
Normal file
54
frontend/src/lib/components/forms/GroupedSelect.svelte
Normal 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>
|
||||||
44
frontend/src/lib/components/forms/HintCheckbox.svelte
Normal file
44
frontend/src/lib/components/forms/HintCheckbox.svelte
Normal 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>
|
||||||
45
frontend/src/lib/components/forms/LabeledInputField.svelte
Normal file
45
frontend/src/lib/components/forms/LabeledInputField.svelte
Normal 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>
|
||||||
49
frontend/src/lib/components/forms/SimpleSelect.svelte
Normal file
49
frontend/src/lib/components/forms/SimpleSelect.svelte
Normal 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>
|
||||||
5
frontend/src/lib/components/forms/form-classes.ts
Normal file
5
frontend/src/lib/components/forms/form-classes.ts
Normal 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';
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<section class="editorial-hero reveal-1">
|
||||||
<!-- Latest skin snapshot -->
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<Card>
|
<h2 class="editorial-title">{m.dashboard_title()}</h2>
|
||||||
<CardHeader>
|
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p>
|
||||||
<CardTitle>{m["dashboard_latestSnapshot"]()}</CardTitle>
|
|
||||||
</CardHeader>
|
{#if data.latestSnapshot}
|
||||||
<CardContent>
|
{@const snapshot = data.latestSnapshot}
|
||||||
{#if data.latestSnapshot}
|
<div class="hero-strip">
|
||||||
{@const s = data.latestSnapshot}
|
<div>
|
||||||
<div class="space-y-3">
|
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p>
|
||||||
<div class="flex items-center justify-between">
|
<p class="hero-strip-value">{snapshot.snapshot_date}</p>
|
||||||
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
|
</div>
|
||||||
{#if s.overall_state}
|
{#if snapshot.overall_state}
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
|
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
|
||||||
{s.overall_state}
|
{humanize(snapshot.overall_state)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if s.active_concerns.length}
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each s.active_concerns as concern (concern)}
|
|
||||||
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if s.notes}
|
|
||||||
<p class="text-sm text-muted-foreground">{s.notes}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-muted-foreground">{m["dashboard_noSnapshots"]()}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Recent routines -->
|
<div class="editorial-grid">
|
||||||
<Card>
|
<section class="editorial-panel reveal-2">
|
||||||
<CardHeader>
|
<header class="panel-header">
|
||||||
<CardTitle>{m["dashboard_recentRoutines"]()}</CardTitle>
|
<p class="panel-index">01</p>
|
||||||
</CardHeader>
|
<h3>{m["dashboard_latestSnapshot"]()}</h3>
|
||||||
<CardContent>
|
</header>
|
||||||
{#if data.recentRoutines.length}
|
<div class="panel-action-row">
|
||||||
<ul class="space-y-2">
|
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
|
||||||
{#each data.recentRoutines as routine (routine.id)}
|
</div>
|
||||||
<li class="flex items-center justify-between">
|
|
||||||
<a href="/routines/{routine.id}" class="text-sm hover:underline">
|
{#if data.latestSnapshot}
|
||||||
{routine.routine_date}
|
{@const s = data.latestSnapshot}
|
||||||
</a>
|
<div class="snapshot-meta-row">
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<span class="snapshot-date">{s.snapshot_date}</span>
|
||||||
{routine.part_of_day.toUpperCase()}
|
{#if s.overall_state}
|
||||||
</Badge>
|
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
|
||||||
</li>
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if s.active_concerns.length}
|
||||||
|
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}>
|
||||||
|
{#each s.active_concerns as concern (concern)}
|
||||||
|
<span class="concern-chip">{humanize(concern)}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-muted-foreground">{m["dashboard_noRoutines"]()}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{#if s.notes}
|
||||||
|
<p class="snapshot-notes">{s.notes}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editorial-panel reveal-3">
|
||||||
|
<header class="panel-header">
|
||||||
|
<p class="panel-index">02</p>
|
||||||
|
<h3>{m["dashboard_recentRoutines"]()}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.recentRoutines.length}
|
||||||
|
<div class="routine-summary-strip">
|
||||||
|
<span class="routine-summary-chip">AM {routineStats.amCount}</span>
|
||||||
|
<span class="routine-summary-chip">PM {routineStats.pmCount}</span>
|
||||||
|
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
|
||||||
|
</div>
|
||||||
|
<ol class="routine-list">
|
||||||
|
{#each data.recentRoutines as routine (routine.id)}
|
||||||
|
<li class="routine-item">
|
||||||
|
<a href={resolve(`/routines/${routine.id}`)} class="routine-link">
|
||||||
|
<div class="routine-main">
|
||||||
|
<div class="routine-topline">
|
||||||
|
<span class="routine-date">{routine.routine_date}</span>
|
||||||
|
<span class={routineTone[routine.part_of_day] ?? 'routine-pill'}>
|
||||||
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="routine-meta">
|
||||||
|
<span>{routine.steps?.length ?? 0} {m.common_steps()}</span>
|
||||||
|
{#if routine.notes}
|
||||||
|
<span class="routine-note-inline">{m.routines_notes()}: {routine.notes}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 class="editorial-toolbar">
|
||||||
|
<Button href={resolve('/health/medications')} variant="outline">{m.medications_title()}</Button>
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
</section>
|
||||||
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#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>
|
{#each flags as f (f)}
|
||||||
<SelectItem value="">{m["labResults_flagAll"]()}</SelectItem>
|
<option value={f}>{f}</option>
|
||||||
{#each flags as f (f)}
|
{/each}
|
||||||
<SelectItem value={f}>{f}</SelectItem>
|
</select>
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</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}
|
||||||
|
|
|
||||||
|
|
@ -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 class="editorial-toolbar">
|
||||||
|
<Button href={resolve('/health/lab-results')} variant="outline">{m["labResults_title"]()}</Button>
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
</section>
|
||||||
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#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>
|
||||||
|
|
|
||||||
|
|
@ -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,63 +106,116 @@
|
||||||
})());
|
})());
|
||||||
|
|
||||||
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">
|
||||||
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
<div class="editorial-filter-row">
|
||||||
<Button
|
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
||||||
variant={ownershipFilter === f ? 'default' : 'outline'}
|
<Button
|
||||||
size="sm"
|
variant={ownershipFilter === f ? 'default' : 'outline'}
|
||||||
onclick={() => ownershipFilter = f}
|
size="sm"
|
||||||
>
|
onclick={() => ownershipFilter = f}
|
||||||
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
|
>
|
||||||
</Button>
|
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
|
||||||
{/each}
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="w-full lg:max-w-md">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
|
||||||
|
{m['products_colBrand']()}
|
||||||
|
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
|
||||||
|
{m['products_colName']()}
|
||||||
|
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
|
||||||
|
{m['products_colTime']()}
|
||||||
|
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
|
||||||
|
{m["products_colPricePerUse"]()}
|
||||||
|
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<TableHead>{m["products_colName"]()}</TableHead>
|
<TableHead>{m["products_colName"]()}</TableHead>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,252 +17,330 @@
|
||||||
|
|
||||||
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"
|
||||||
</div>
|
disabled={activeTab !== 'edit' || !isEditDirty}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Save class="size-4" aria-hidden="true" />
|
||||||
|
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span>
|
||||||
|
</Button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => {
|
||||||
|
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
<span class="sr-only md:not-sr-only">{m["products_deleteProduct"]()}</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editorial-page space-y-4 pb-20 md:pb-0">
|
||||||
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
|
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
|
<h2 class="break-words editorial-title">{product.name}</h2>
|
||||||
|
<div class="products-meta-strip">
|
||||||
|
<span class="font-medium text-foreground">{product.brand}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span class="uppercase">{product.recommended_time}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{product.category.replace(/_/g, ' ')}</span>
|
||||||
|
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#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 class="space-y-3">
|
||||||
</div>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
</form>
|
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
|
||||||
|
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
{#if form?.inventoryAdded}
|
||||||
|
<div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.inventoryUpdated}
|
||||||
|
<div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.inventoryDeleted}
|
||||||
|
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Inventory -->
|
{#if showInventoryForm}
|
||||||
<div class="space-y-3">
|
<Card>
|
||||||
<div class="flex items-center justify-between">
|
<CardHeader class="pb-2">
|
||||||
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
|
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
|
||||||
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
|
</CardHeader>
|
||||||
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
|
<CardContent>
|
||||||
</Button>
|
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
</div>
|
<div class="sm:col-span-2 flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
|
||||||
|
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
||||||
|
<Input id="add_opened_at" name="opened_at" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
|
||||||
|
<Input id="add_finished_at" name="finished_at" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
|
||||||
|
<Input id="add_expiry_date" name="expiry_date" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
|
||||||
|
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
|
||||||
|
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_notes">{m.inventory_notes()}</Label>
|
||||||
|
<Input id="add_notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button type="submit" size="sm">{m.common_add()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if form?.inventoryAdded}
|
{#if product.inventory.length}
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageAdded"]()}</div>
|
<div class="space-y-2">
|
||||||
{/if}
|
{#each product.inventory as pkg (pkg.id)}
|
||||||
{#if form?.inventoryUpdated}
|
<div class="rounded-md border border-border text-sm">
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageUpdated"]()}</div>
|
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
{/if}
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
{#if form?.inventoryDeleted}
|
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageDeleted"]()}</div>
|
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
|
||||||
{/if}
|
</Badge>
|
||||||
|
{#if pkg.finished_at}
|
||||||
|
<Badge variant="outline">{m["inventory_badgeFinished"]()}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.expiry_date}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_exp()} {pkg.expiry_date.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.opened_at}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_opened()} {pkg.opened_at.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.finished_at}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.current_weight_g}
|
||||||
|
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.last_weighed_at}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.notes}
|
||||||
|
<span class="text-muted-foreground">{pkg.notes}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
|
||||||
|
>
|
||||||
|
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
|
||||||
|
</Button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteInventory"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="inventory_id" value={pkg.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if showInventoryForm}
|
{#if editingInventoryId === pkg.id}
|
||||||
|
<div class="border-t px-4 py-3">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateInventory"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') editingInventoryId = null;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="inventory_id" value={pkg.id} />
|
||||||
|
<div class="sm:col-span-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit_is_opened_{pkg.id}"
|
||||||
|
name="is_opened"
|
||||||
|
value="true"
|
||||||
|
checked={pkg.is_opened}
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
|
||||||
|
<Input id="edit_opened_at_{pkg.id}" name="opened_at" type="date" value={pkg.opened_at?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
|
||||||
|
<Input id="edit_finished_at_{pkg.id}" name="finished_at" type="date" value={pkg.finished_at?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
|
||||||
|
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
|
||||||
|
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
|
||||||
|
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
||||||
|
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<Button type="submit" size="sm">{m.common_save()}</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onclick={() => (editingInventoryId = null)}>{m.common_cancel()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="edit" class="space-y-4 pt-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="pt-4">
|
<CardHeader class="pb-2">
|
||||||
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="col-span-2 flex items-center gap-2">
|
<CardTitle class="text-base">{m.common_edit()}</CardTitle>
|
||||||
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
|
<Button type="button" variant="outline" size="sm" onclick={() => productFormRef?.openAiModal()}>
|
||||||
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
<Sparkles class="size-4" />
|
||||||
</div>
|
{m["productForm_aiPrefill"]()}
|
||||||
<div class="space-y-1">
|
</Button>
|
||||||
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
</div>
|
||||||
<Input id="add_opened_at" name="opened_at" type="date" />
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
<div class="space-y-1">
|
<form
|
||||||
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
|
id="product-edit-form"
|
||||||
<Input id="add_finished_at" name="finished_at" type="date" />
|
method="POST"
|
||||||
</div>
|
action="?/update"
|
||||||
<div class="space-y-1">
|
use:enhance={() => {
|
||||||
<Label for="expiry_date">{m["inventory_expiryDate"]()}</Label>
|
return async ({ result, update }) => {
|
||||||
<Input id="expiry_date" name="expiry_date" type="date" />
|
await update();
|
||||||
</div>
|
if (result.type === 'success') {
|
||||||
<div class="space-y-1">
|
isEditDirty = false;
|
||||||
<Label for="current_weight_g">{m["inventory_currentWeight"]()}</Label>
|
editSaveVersion += 1;
|
||||||
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
|
}
|
||||||
</div>
|
};
|
||||||
<div class="space-y-1">
|
}}
|
||||||
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
|
class="space-y-6"
|
||||||
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
|
>
|
||||||
</div>
|
<ProductForm
|
||||||
<div class="space-y-1">
|
bind:this={productFormRef}
|
||||||
<Label for="notes">{m.inventory_notes()}</Label>
|
{product}
|
||||||
<Input id="notes" name="notes" />
|
onDirtyChange={(dirty) => (isEditDirty = dirty)}
|
||||||
</div>
|
saveVersion={editSaveVersion}
|
||||||
<div class="flex items-end">
|
showAiTrigger={false}
|
||||||
<Button type="submit" size="sm">{m.common_add()}</Button>
|
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
|
||||||
</div>
|
computedPricePerUseLabel={formatPricePerUse(getPricePerUse())}
|
||||||
|
computedPriceTierLabel={formatTier(product.price_tier)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
{#if product.inventory.length}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each product.inventory as pkg (pkg.id)}
|
|
||||||
<div class="rounded-md border border-border text-sm">
|
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
|
||||||
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
|
|
||||||
</Badge>
|
|
||||||
{#if pkg.finished_at}
|
|
||||||
<Badge variant="outline">{m["inventory_badgeFinished"]()}</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.expiry_date}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_exp()} {pkg.expiry_date.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.opened_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_opened()} {pkg.opened_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.finished_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.current_weight_g}
|
|
||||||
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.last_weighed_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.notes}
|
|
||||||
<span class="text-muted-foreground">{pkg.notes}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
|
|
||||||
>
|
|
||||||
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
|
|
||||||
</Button>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteInventory"
|
|
||||||
use:enhance
|
|
||||||
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="inventory_id" value={pkg.id} />
|
|
||||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">×</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if editingInventoryId === pkg.id}
|
|
||||||
<div class="border-t px-4 py-3">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updateInventory"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
await update();
|
|
||||||
if (result.type === 'success') editingInventoryId = null;
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="grid grid-cols-2 gap-4"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="inventory_id" value={pkg.id} />
|
|
||||||
<div class="col-span-2 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="edit_is_opened_{pkg.id}"
|
|
||||||
name="is_opened"
|
|
||||||
value="true"
|
|
||||||
checked={pkg.is_opened}
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_opened_at_{pkg.id}"
|
|
||||||
name="opened_at"
|
|
||||||
type="date"
|
|
||||||
value={pkg.opened_at?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_finished_at_{pkg.id}"
|
|
||||||
name="finished_at"
|
|
||||||
type="date"
|
|
||||||
value={pkg.finished_at?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_expiry_{pkg.id}"
|
|
||||||
name="expiry_date"
|
|
||||||
type="date"
|
|
||||||
value={pkg.expiry_date?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_weight_{pkg.id}"
|
|
||||||
name="current_weight_g"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={pkg.current_weight_g ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_last_weighed_{pkg.id}"
|
|
||||||
name="last_weighed_at"
|
|
||||||
type="date"
|
|
||||||
value={pkg.last_weighed_at?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
|
||||||
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end gap-2">
|
|
||||||
<Button type="submit" size="sm">{m.common_save()}</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (editingInventoryId = null)}
|
|
||||||
>{m.common_cancel()}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Danger zone -->
|
|
||||||
<div>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/delete"
|
|
||||||
use:enhance
|
|
||||||
onsubmit={(e) => {
|
|
||||||
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<div class="flex items-center gap-3">
|
||||||
{routine.part_of_day.toUpperCase()}
|
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
|
||||||
</Badge>
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
</div>
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</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}
|
||||||
value={editDraft.product_id ?? ''}
|
placeholder={m["routines_selectProduct"]()}
|
||||||
onValueChange={(v) => (editDraft.product_id = v || undefined)}
|
value={editDraft.product_id ?? ''}
|
||||||
>
|
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 -->
|
||||||
|
<SimpleSelect
|
||||||
|
id={`edit_step_action_${step.id}`}
|
||||||
|
label={m["routines_action"]()}
|
||||||
|
options={groomingActionOptions}
|
||||||
|
placeholder={m["routines_selectAction"]()}
|
||||||
|
value={editDraft.action_type ?? ''}
|
||||||
|
onChange={(value) =>
|
||||||
|
(editDraft.action_type = (value || undefined) as GroomingAction | undefined)}
|
||||||
|
/>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>Action</Label>
|
<Label>{m.routines_notes()}</Label>
|
||||||
<Select
|
|
||||||
type="single"
|
|
||||||
value={editDraft.action_type ?? ''}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{#each GROOMING_ACTIONS as action (action)}
|
|
||||||
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
|
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
<Input
|
<Input
|
||||||
value={editDraft.action_notes ?? ''}
|
value={editDraft.action_notes ?? ''}
|
||||||
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
|
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
|
||||||
placeholder="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,16 +309,16 @@
|
||||||
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
|
||||||
type="submit"
|
type="submit"
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
label={m["suggest_minoxidilToggleLabel"]()}
|
||||||
type="checkbox"
|
hint={m["suggest_minoxidilToggleHint"]()}
|
||||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
/>
|
||||||
/>
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={loadingSingle} class="w-full">
|
<Button type="submit" disabled={loadingSingle} class="w-full">
|
||||||
{#if loadingSingle}
|
{#if loadingSingle}
|
||||||
|
|
@ -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"
|
label={m["suggest_minoxidilToggleLabel"]()}
|
||||||
type="checkbox"
|
hint={m["suggest_minoxidilToggleHint"]()}
|
||||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
/>
|
||||||
/>
|
<HintCheckbox
|
||||||
<div class="space-y-0.5">
|
id="batch_minimize_products"
|
||||||
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
name="minimize_products"
|
||||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
label={m["suggest_minimizeProductsLabel"]()}
|
||||||
</div>
|
hint={m["suggest_minimizeProductsHint"]()}
|
||||||
</div>
|
/>
|
||||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
|
||||||
<input
|
|
||||||
id="batch_minimize_products"
|
|
||||||
name="minimize_products"
|
|
||||||
type="checkbox"
|
|
||||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
|
||||||
/>
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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}
|
||||||
|
|
|
||||||
|
|
@ -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 class="editorial-toolbar">
|
||||||
|
{#if showForm}
|
||||||
|
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
||||||
|
<Sparkles class="size-4" />
|
||||||
|
{m["skin_aiAnalysisTitle"]()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
</section>
|
||||||
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#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>
|
<button
|
||||||
<CardHeader>
|
type="button"
|
||||||
<button
|
class="fixed inset-0 z-50 bg-black/50"
|
||||||
type="button"
|
onclick={closeAiModal}
|
||||||
class="flex w-full items-center justify-between text-left"
|
aria-label={m.common_cancel()}
|
||||||
onclick={() => (aiPanelOpen = !aiPanelOpen)}
|
></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">
|
||||||
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
|
<Card class="max-h-full w-full overflow-hidden">
|
||||||
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
<CardHeader class="border-b border-border">
|
||||||
</button>
|
<div class="flex items-center justify-between gap-3">
|
||||||
</CardHeader>
|
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
|
||||||
{#if aiPanelOpen}
|
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
|
||||||
<CardContent class="space-y-3">
|
<X class="size-4" />
|
||||||
<p class="text-sm text-muted-foreground">
|
</Button>
|
||||||
{m["skin_aiUploadText"]()}
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
|
|
||||||
multiple
|
|
||||||
onchange={handleFileSelect}
|
|
||||||
class="block w-full text-sm text-muted-foreground
|
|
||||||
file:mr-4 file:rounded-md file:border-0 file:bg-primary
|
|
||||||
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
|
||||||
/>
|
|
||||||
{#if previewUrls.length}
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each previewUrls as url (url)}
|
|
||||||
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</CardHeader>
|
||||||
{#if aiError}
|
<CardContent class="space-y-3 overflow-y-auto p-4">
|
||||||
<p class="text-sm text-destructive">{aiError}</p>
|
<p class="text-sm text-muted-foreground">{m["skin_aiUploadText"]()}</p>
|
||||||
{/if}
|
<input
|
||||||
<Button
|
type="file"
|
||||||
type="button"
|
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
|
||||||
onclick={analyzePhotos}
|
multiple
|
||||||
disabled={aiLoading || !selectedFiles.length}
|
onchange={handleFileSelect}
|
||||||
>
|
class="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
||||||
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
|
/>
|
||||||
</Button>
|
{#if previewUrls.length}
|
||||||
</CardContent>
|
<div class="flex flex-wrap gap-2">
|
||||||
{/if}
|
{#each previewUrls as url (url)}
|
||||||
</Card>
|
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if aiError}
|
||||||
|
<p class="text-sm text-destructive">{aiError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
|
||||||
|
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- New snapshot form -->
|
<!-- 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>
|
id="snapshot_date"
|
||||||
<Input
|
name="snapshot_date"
|
||||||
id="snapshot_date"
|
label={m.skin_date()}
|
||||||
name="snapshot_date"
|
type="date"
|
||||||
type="date"
|
required
|
||||||
bind:value={snapshotDate}
|
bind:value={snapshotDate}
|
||||||
required
|
/>
|
||||||
/>
|
<SimpleSelect
|
||||||
</div>
|
id="overall_state"
|
||||||
<div class="space-y-1">
|
name="overall_state"
|
||||||
<Label>{m["skin_overallState"]()}</Label>
|
label={m["skin_overallState"]()}
|
||||||
<input type="hidden" name="overall_state" value={overallState} />
|
options={overallStateOptions}
|
||||||
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
placeholder={m.common_select()}
|
||||||
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
|
bind:value={overallState}
|
||||||
<SelectContent>
|
/>
|
||||||
{#each states as s (s)}
|
<SimpleSelect
|
||||||
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
|
id="texture"
|
||||||
{/each}
|
name="texture"
|
||||||
</SelectContent>
|
label={m.skin_texture()}
|
||||||
</Select>
|
options={textureOptions}
|
||||||
</div>
|
placeholder={m.common_select()}
|
||||||
<div class="space-y-1">
|
bind:value={texture}
|
||||||
<Label>{m.skin_texture()}</Label>
|
/>
|
||||||
<input type="hidden" name="texture" value={texture} />
|
<SimpleSelect
|
||||||
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
id="skin_type"
|
||||||
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
|
name="skin_type"
|
||||||
<SelectContent>
|
label={m["skin_skinType"]()}
|
||||||
{#each skinTextures as t (t)}
|
options={skinTypeOptions}
|
||||||
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
|
placeholder={m.common_select()}
|
||||||
{/each}
|
bind:value={skinType}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<SimpleSelect
|
||||||
</div>
|
id="barrier_state"
|
||||||
<div class="space-y-1">
|
name="barrier_state"
|
||||||
<Label>{m["skin_skinType"]()}</Label>
|
label={m["skin_barrierState"]()}
|
||||||
<input type="hidden" name="skin_type" value={skinType} />
|
options={barrierOptions}
|
||||||
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
|
placeholder={m.common_select()}
|
||||||
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
|
bind:value={barrierState}
|
||||||
<SelectContent>
|
/>
|
||||||
{#each skinTypes as st (st)}
|
<LabeledInputField
|
||||||
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
|
id="hydration_level"
|
||||||
{/each}
|
name="hydration_level"
|
||||||
</SelectContent>
|
label={m.skin_hydration()}
|
||||||
</Select>
|
type="number"
|
||||||
</div>
|
min="1"
|
||||||
<div class="space-y-1">
|
max="5"
|
||||||
<Label>{m["skin_barrierState"]()}</Label>
|
bind:value={hydrationLevel}
|
||||||
<input type="hidden" name="barrier_state" value={barrierState} />
|
/>
|
||||||
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
<LabeledInputField
|
||||||
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
|
id="sensitivity_level"
|
||||||
<SelectContent>
|
name="sensitivity_level"
|
||||||
{#each barrierStates as b (b)}
|
label={m.skin_sensitivity()}
|
||||||
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
|
type="number"
|
||||||
{/each}
|
min="1"
|
||||||
</SelectContent>
|
max="5"
|
||||||
</Select>
|
bind:value={sensitivityLevel}
|
||||||
</div>
|
/>
|
||||||
<div class="space-y-1">
|
<LabeledInputField
|
||||||
<Label for="hydration_level">{m.skin_hydration()}</Label>
|
id="sebum_tzone"
|
||||||
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
|
name="sebum_tzone"
|
||||||
</div>
|
label={m["skin_sebumTzone"]()}
|
||||||
<div class="space-y-1">
|
type="number"
|
||||||
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
|
min="1"
|
||||||
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
|
max="5"
|
||||||
</div>
|
bind:value={sebumTzone}
|
||||||
<div class="space-y-1">
|
/>
|
||||||
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
|
<LabeledInputField
|
||||||
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
|
id="sebum_cheeks"
|
||||||
</div>
|
name="sebum_cheeks"
|
||||||
<div class="space-y-1">
|
label={m["skin_sebumCheeks"]()}
|
||||||
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
|
type="number"
|
||||||
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
|
min="1"
|
||||||
</div>
|
max="5"
|
||||||
<div class="space-y-1 col-span-2">
|
bind:value={sebumCheeks}
|
||||||
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
|
/>
|
||||||
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
|
<LabeledInputField
|
||||||
</div>
|
id="active_concerns"
|
||||||
<div class="space-y-1 col-span-2">
|
name="active_concerns"
|
||||||
<Label for="priorities">{m["skin_priorities"]()}</Label>
|
label={m["skin_activeConcerns"]()}
|
||||||
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
|
placeholder={m["skin_activeConcernsPlaceholder"]()}
|
||||||
</div>
|
className="space-y-1 col-span-2"
|
||||||
<div class="space-y-1 col-span-2">
|
bind:value={activeConcernsRaw}
|
||||||
<Label for="notes">{m.skin_notes()}</Label>
|
/>
|
||||||
<Input id="notes" name="notes" bind:value={notes} />
|
<LabeledInputField
|
||||||
</div>
|
id="priorities"
|
||||||
|
name="priorities"
|
||||||
|
label={m["skin_priorities"]()}
|
||||||
|
placeholder={m["skin_prioritiesPlaceholder"]()}
|
||||||
|
className="space-y-1 col-span-2"
|
||||||
|
bind:value={prioritiesRaw}
|
||||||
|
/>
|
||||||
|
<LabeledInputField
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
label={m.skin_notes()}
|
||||||
|
className="space-y-1 col-span-2"
|
||||||
|
bind:value={notes}
|
||||||
|
/>
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
|
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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,19 +489,19 @@
|
||||||
<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}
|
||||||
{#if snap.texture}
|
{#if snap.texture}
|
||||||
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue