refactor: remove routine_role, recommended_frequency, evidence_level, cumulative_with
Drop fields identified as redundant or low-value from the Product model, API schemas, frontend types, and forms. Raise effect_profile threshold in to_llm_context() from >0 to >=2 to suppress noise values. Remove sku/barcode from LLM context output (kept on model for catalog use). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9bf94a979c
commit
9a069508af
9 changed files with 464 additions and 142 deletions
|
|
@ -21,11 +21,8 @@ from innercontext.models.product import (
|
||||||
from innercontext.models.enums import (
|
from innercontext.models.enums import (
|
||||||
AbsorptionSpeed,
|
AbsorptionSpeed,
|
||||||
DayTime,
|
DayTime,
|
||||||
EvidenceLevel,
|
|
||||||
PriceTier,
|
PriceTier,
|
||||||
RoutineRole,
|
|
||||||
TextureType,
|
TextureType,
|
||||||
UsageFrequency,
|
|
||||||
SkinType,
|
SkinType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -46,7 +43,6 @@ class ProductCreate(SQLModel):
|
||||||
barcode: Optional[str] = None
|
barcode: Optional[str] = None
|
||||||
|
|
||||||
category: ProductCategory
|
category: ProductCategory
|
||||||
routine_role: RoutineRole
|
|
||||||
recommended_time: DayTime
|
recommended_time: DayTime
|
||||||
|
|
||||||
texture: Optional[TextureType] = None
|
texture: Optional[TextureType] = None
|
||||||
|
|
@ -61,13 +57,10 @@ class ProductCreate(SQLModel):
|
||||||
actives: Optional[list[ActiveIngredient]] = None
|
actives: Optional[list[ActiveIngredient]] = None
|
||||||
|
|
||||||
recommended_for: list[SkinType] = []
|
recommended_for: list[SkinType] = []
|
||||||
recommended_frequency: Optional[UsageFrequency] = None
|
|
||||||
|
|
||||||
targets: list[SkinConcern] = []
|
targets: list[SkinConcern] = []
|
||||||
contraindications: list[str] = []
|
contraindications: list[str] = []
|
||||||
usage_notes: Optional[str] = None
|
usage_notes: Optional[str] = None
|
||||||
evidence_level: Optional[EvidenceLevel] = None
|
|
||||||
claims: list[str] = []
|
|
||||||
|
|
||||||
fragrance_free: Optional[bool] = None
|
fragrance_free: Optional[bool] = None
|
||||||
essential_oils_free: Optional[bool] = None
|
essential_oils_free: Optional[bool] = None
|
||||||
|
|
@ -104,7 +97,6 @@ class ProductUpdate(SQLModel):
|
||||||
barcode: Optional[str] = None
|
barcode: Optional[str] = None
|
||||||
|
|
||||||
category: Optional[ProductCategory] = None
|
category: Optional[ProductCategory] = None
|
||||||
routine_role: Optional[RoutineRole] = None
|
|
||||||
recommended_time: Optional[DayTime] = None
|
recommended_time: Optional[DayTime] = None
|
||||||
|
|
||||||
texture: Optional[TextureType] = None
|
texture: Optional[TextureType] = None
|
||||||
|
|
@ -119,13 +111,10 @@ class ProductUpdate(SQLModel):
|
||||||
actives: Optional[list[ActiveIngredient]] = None
|
actives: Optional[list[ActiveIngredient]] = None
|
||||||
|
|
||||||
recommended_for: Optional[list[SkinType]] = None
|
recommended_for: Optional[list[SkinType]] = None
|
||||||
recommended_frequency: Optional[UsageFrequency] = None
|
|
||||||
|
|
||||||
targets: Optional[list[SkinConcern]] = None
|
targets: Optional[list[SkinConcern]] = None
|
||||||
contraindications: Optional[list[str]] = None
|
contraindications: Optional[list[str]] = None
|
||||||
usage_notes: Optional[str] = None
|
usage_notes: Optional[str] = None
|
||||||
evidence_level: Optional[EvidenceLevel] = None
|
|
||||||
claims: Optional[list[str]] = None
|
|
||||||
|
|
||||||
fragrance_free: Optional[bool] = None
|
fragrance_free: Optional[bool] = None
|
||||||
essential_oils_free: Optional[bool] = None
|
essential_oils_free: Optional[bool] = None
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,14 @@ from .domain import Domain
|
||||||
from .enums import (
|
from .enums import (
|
||||||
AbsorptionSpeed,
|
AbsorptionSpeed,
|
||||||
DayTime,
|
DayTime,
|
||||||
EvidenceLevel,
|
|
||||||
IngredientFunction,
|
IngredientFunction,
|
||||||
InteractionScope,
|
InteractionScope,
|
||||||
PriceTier,
|
PriceTier,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
RoutineRole,
|
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
SkinType,
|
SkinType,
|
||||||
StrengthLevel,
|
StrengthLevel,
|
||||||
TextureType,
|
TextureType,
|
||||||
UsageFrequency,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,8 +57,6 @@ class ActiveIngredient(SQLModel):
|
||||||
strength_level: StrengthLevel | None = None
|
strength_level: StrengthLevel | None = None
|
||||||
irritation_potential: StrengthLevel | None = None
|
irritation_potential: StrengthLevel | None = None
|
||||||
|
|
||||||
cumulative_with: list[IngredientFunction] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProductInteraction(SQLModel):
|
class ProductInteraction(SQLModel):
|
||||||
target: str
|
target: str
|
||||||
|
|
@ -106,7 +101,6 @@ class Product(SQLModel, table=True):
|
||||||
barcode: str | None = Field(default=None, max_length=64)
|
barcode: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
category: ProductCategory
|
category: ProductCategory
|
||||||
routine_role: RoutineRole
|
|
||||||
recommended_time: DayTime
|
recommended_time: DayTime
|
||||||
|
|
||||||
texture: TextureType | None = None
|
texture: TextureType | None = None
|
||||||
|
|
@ -127,7 +121,6 @@ class Product(SQLModel, table=True):
|
||||||
recommended_for: list[SkinType] = Field(
|
recommended_for: list[SkinType] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
recommended_frequency: UsageFrequency | None = None
|
|
||||||
|
|
||||||
targets: list[SkinConcern] = Field(
|
targets: list[SkinConcern] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
|
|
@ -136,10 +129,6 @@ class Product(SQLModel, table=True):
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
usage_notes: str | None = None
|
usage_notes: str | None = None
|
||||||
evidence_level: EvidenceLevel | None = Field(default=None, index=True)
|
|
||||||
claims: list[str] = Field(
|
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
fragrance_free: bool | None = None
|
fragrance_free: bool | None = None
|
||||||
essential_oils_free: bool | None = None
|
essential_oils_free: bool | None = None
|
||||||
|
|
@ -221,12 +210,11 @@ class Product(SQLModel, table=True):
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"brand": self.brand,
|
"brand": self.brand,
|
||||||
"category": _ev(self.category),
|
"category": _ev(self.category),
|
||||||
"routine_role": _ev(self.routine_role),
|
|
||||||
"recommended_time": _ev(self.recommended_time),
|
"recommended_time": _ev(self.recommended_time),
|
||||||
"leave_on": self.leave_on,
|
"leave_on": self.leave_on,
|
||||||
}
|
}
|
||||||
|
|
||||||
for field in ("line_name", "sku", "url", "barcode"):
|
for field in ("line_name", "url"):
|
||||||
val = getattr(self, field)
|
val = getattr(self, field)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
ctx[field] = val
|
ctx[field] = val
|
||||||
|
|
@ -241,11 +229,6 @@ class Product(SQLModel, table=True):
|
||||||
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:
|
||||||
ctx["pao_months"] = self.pao_months
|
ctx["pao_months"] = self.pao_months
|
||||||
if self.recommended_frequency is not None:
|
|
||||||
ctx["recommended_frequency"] = _ev(self.recommended_frequency)
|
|
||||||
if self.evidence_level is not None:
|
|
||||||
ctx["evidence_level"] = _ev(self.evidence_level)
|
|
||||||
|
|
||||||
if self.inci:
|
if self.inci:
|
||||||
ctx["inci"] = self.inci
|
ctx["inci"] = self.inci
|
||||||
if self.recommended_for:
|
if self.recommended_for:
|
||||||
|
|
@ -254,8 +237,6 @@ class Product(SQLModel, table=True):
|
||||||
ctx["targets"] = [_ev(s) for s in self.targets]
|
ctx["targets"] = [_ev(s) for s in self.targets]
|
||||||
if self.contraindications:
|
if self.contraindications:
|
||||||
ctx["contraindications"] = self.contraindications
|
ctx["contraindications"] = self.contraindications
|
||||||
if self.claims:
|
|
||||||
ctx["claims"] = self.claims
|
|
||||||
|
|
||||||
if self.actives:
|
if self.actives:
|
||||||
actives_ctx = []
|
actives_ctx = []
|
||||||
|
|
@ -270,8 +251,6 @@ class Product(SQLModel, table=True):
|
||||||
a_dict["functions"] = [_ev(f) for f in a.functions]
|
a_dict["functions"] = [_ev(f) for f in a.functions]
|
||||||
if a.strength_level is not None:
|
if a.strength_level is not None:
|
||||||
a_dict["strength_level"] = a.strength_level.name.lower()
|
a_dict["strength_level"] = a.strength_level.name.lower()
|
||||||
if a.cumulative_with:
|
|
||||||
a_dict["cumulative_with"] = [_ev(f) for f in a.cumulative_with]
|
|
||||||
actives_ctx.append(a_dict)
|
actives_ctx.append(a_dict)
|
||||||
ctx["actives"] = actives_ctx
|
ctx["actives"] = actives_ctx
|
||||||
|
|
||||||
|
|
@ -288,9 +267,9 @@ class Product(SQLModel, table=True):
|
||||||
ep = self.product_effect_profile
|
ep = self.product_effect_profile
|
||||||
if ep is not None:
|
if ep is not None:
|
||||||
if isinstance(ep, dict):
|
if isinstance(ep, dict):
|
||||||
nonzero = {k: v for k, v in ep.items() if v}
|
nonzero = {k: v for k, v in ep.items() if v >= 2}
|
||||||
else:
|
else:
|
||||||
nonzero = {k: v for k, v in ep.model_dump().items() if v}
|
nonzero = {k: v for k, v in ep.model_dump().items() if v >= 2}
|
||||||
if nonzero:
|
if nonzero:
|
||||||
ctx["effect_profile"] = nonzero
|
ctx["effect_profile"] = nonzero
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ def product_data():
|
||||||
"name": "CeraVe Moisturising Cream",
|
"name": "CeraVe Moisturising Cream",
|
||||||
"brand": "CeraVe",
|
"brand": "CeraVe",
|
||||||
"category": "moisturizer",
|
"category": "moisturizer",
|
||||||
"routine_role": "seal",
|
|
||||||
"recommended_time": "both",
|
"recommended_time": "both",
|
||||||
"leave_on": True,
|
"leave_on": True,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from innercontext.models.enums import (
|
||||||
IngredientFunction,
|
IngredientFunction,
|
||||||
InteractionScope,
|
InteractionScope,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
RoutineRole,
|
|
||||||
)
|
)
|
||||||
from innercontext.models.product import (
|
from innercontext.models.product import (
|
||||||
ActiveIngredient,
|
ActiveIngredient,
|
||||||
|
|
@ -25,7 +24,6 @@ def _make(**kwargs):
|
||||||
name="Test",
|
name="Test",
|
||||||
brand="B",
|
brand="B",
|
||||||
category=ProductCategory.MOISTURIZER,
|
category=ProductCategory.MOISTURIZER,
|
||||||
routine_role=RoutineRole.SEAL,
|
|
||||||
recommended_time=DayTime.BOTH,
|
recommended_time=DayTime.BOTH,
|
||||||
leave_on=True,
|
leave_on=True,
|
||||||
)
|
)
|
||||||
|
|
@ -41,7 +39,7 @@ def _make(**kwargs):
|
||||||
def test_always_present_keys():
|
def test_always_present_keys():
|
||||||
p = _make()
|
p = _make()
|
||||||
ctx = p.to_llm_context()
|
ctx = p.to_llm_context()
|
||||||
for key in ("id", "name", "brand", "category", "routine_role", "recommended_time", "leave_on"):
|
for key in ("id", "name", "brand", "category", "recommended_time", "leave_on"):
|
||||||
assert key in ctx, f"Expected '{key}' in to_llm_context() output"
|
assert key in ctx, f"Expected '{key}' in to_llm_context() output"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,17 +51,17 @@ def test_always_present_keys():
|
||||||
def test_optional_string_fields_absent_when_none():
|
def test_optional_string_fields_absent_when_none():
|
||||||
p = _make()
|
p = _make()
|
||||||
ctx = p.to_llm_context()
|
ctx = p.to_llm_context()
|
||||||
for key in ("line_name", "sku", "url", "barcode"):
|
for key in ("line_name", "url"):
|
||||||
assert key not in ctx, f"'{key}' should not appear when None"
|
assert key not in ctx, f"'{key}' should not appear when None"
|
||||||
|
|
||||||
|
|
||||||
def test_optional_string_fields_present_when_set():
|
def test_optional_string_fields_present_when_set():
|
||||||
p = _make(line_name="Hydrating", sku="CV-001", url="https://example.com", barcode="123456")
|
p = _make(line_name="Hydrating", url="https://example.com")
|
||||||
ctx = p.to_llm_context()
|
ctx = p.to_llm_context()
|
||||||
assert ctx["line_name"] == "Hydrating"
|
assert ctx["line_name"] == "Hydrating"
|
||||||
assert ctx["sku"] == "CV-001"
|
|
||||||
assert ctx["url"] == "https://example.com"
|
assert ctx["url"] == "https://example.com"
|
||||||
assert ctx["barcode"] == "123456"
|
assert "sku" not in ctx
|
||||||
|
assert "barcode" not in ctx
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ def test_list_filter_category(client, client_and_data=None):
|
||||||
# Create a moisturizer and a serum
|
# Create a moisturizer and a serum
|
||||||
base = {
|
base = {
|
||||||
"brand": "B",
|
"brand": "B",
|
||||||
"routine_role": "seal",
|
|
||||||
"recommended_time": "both",
|
"recommended_time": "both",
|
||||||
"leave_on": True,
|
"leave_on": True,
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +71,6 @@ def test_list_filter_category(client, client_and_data=None):
|
||||||
|
|
||||||
def test_list_filter_brand(client):
|
def test_list_filter_brand(client):
|
||||||
base = {
|
base = {
|
||||||
"routine_role": "seal",
|
|
||||||
"recommended_time": "both",
|
"recommended_time": "both",
|
||||||
"leave_on": True,
|
"leave_on": True,
|
||||||
"category": "serum",
|
"category": "serum",
|
||||||
|
|
@ -90,7 +88,6 @@ def test_list_filter_brand(client):
|
||||||
def test_list_filter_is_medication(client):
|
def test_list_filter_is_medication(client):
|
||||||
base = {
|
base = {
|
||||||
"brand": "B",
|
"brand": "B",
|
||||||
"routine_role": "seal",
|
|
||||||
"recommended_time": "both",
|
"recommended_time": "both",
|
||||||
"leave_on": True,
|
"leave_on": True,
|
||||||
"category": "serum",
|
"category": "serum",
|
||||||
|
|
@ -113,7 +110,6 @@ def test_list_filter_is_medication(client):
|
||||||
def test_list_filter_targets(client):
|
def test_list_filter_targets(client):
|
||||||
base = {
|
base = {
|
||||||
"brand": "B",
|
"brand": "B",
|
||||||
"routine_role": "seal",
|
|
||||||
"recommended_time": "both",
|
"recommended_time": "both",
|
||||||
"leave_on": True,
|
"leave_on": True,
|
||||||
"category": "serum",
|
"category": "serum",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow';
|
export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow';
|
||||||
export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised';
|
export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised';
|
||||||
export type DayTime = 'am' | 'pm' | 'both';
|
export type DayTime = 'am' | 'pm' | 'both';
|
||||||
export type EvidenceLevel = 'low' | 'mixed' | 'moderate' | 'high';
|
|
||||||
export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling';
|
export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling';
|
||||||
export type IngredientFunction =
|
export type IngredientFunction =
|
||||||
| 'humectant'
|
| 'humectant'
|
||||||
|
|
@ -44,14 +43,6 @@ export type ProductCategory =
|
||||||
| 'spot_treatment'
|
| 'spot_treatment'
|
||||||
| 'oil';
|
| 'oil';
|
||||||
export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
|
export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
|
||||||
export type RoutineRole =
|
|
||||||
| 'cleanse'
|
|
||||||
| 'prepare'
|
|
||||||
| 'treatment_active'
|
|
||||||
| 'treatment_support'
|
|
||||||
| 'seal'
|
|
||||||
| 'protect'
|
|
||||||
| 'hair_treatment';
|
|
||||||
export type SkinConcern =
|
export type SkinConcern =
|
||||||
| 'acne'
|
| 'acne'
|
||||||
| 'rosacea'
|
| 'rosacea'
|
||||||
|
|
@ -68,15 +59,6 @@ export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating';
|
||||||
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
|
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
|
||||||
export type StrengthLevel = 1 | 2 | 3;
|
export type StrengthLevel = 1 | 2 | 3;
|
||||||
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
|
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
|
||||||
export type UsageFrequency =
|
|
||||||
| 'daily'
|
|
||||||
| 'twice_daily'
|
|
||||||
| 'every_other_day'
|
|
||||||
| 'twice_weekly'
|
|
||||||
| 'three_times_weekly'
|
|
||||||
| 'weekly'
|
|
||||||
| 'as_needed';
|
|
||||||
|
|
||||||
// ─── Product types ───────────────────────────────────────────────────────────
|
// ─── Product types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ActiveIngredient {
|
export interface ActiveIngredient {
|
||||||
|
|
@ -85,7 +67,6 @@ export interface ActiveIngredient {
|
||||||
functions: IngredientFunction[];
|
functions: IngredientFunction[];
|
||||||
strength_level?: StrengthLevel;
|
strength_level?: StrengthLevel;
|
||||||
irritation_potential?: StrengthLevel;
|
irritation_potential?: StrengthLevel;
|
||||||
cumulative_with?: IngredientFunction[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductEffectProfile {
|
export interface ProductEffectProfile {
|
||||||
|
|
@ -140,7 +121,6 @@ export interface Product {
|
||||||
url?: string;
|
url?: string;
|
||||||
barcode?: string;
|
barcode?: string;
|
||||||
category: ProductCategory;
|
category: ProductCategory;
|
||||||
routine_role: RoutineRole;
|
|
||||||
recommended_time: DayTime;
|
recommended_time: DayTime;
|
||||||
texture?: TextureType;
|
texture?: TextureType;
|
||||||
absorption_speed?: AbsorptionSpeed;
|
absorption_speed?: AbsorptionSpeed;
|
||||||
|
|
@ -151,12 +131,9 @@ export interface Product {
|
||||||
inci: string[];
|
inci: string[];
|
||||||
actives?: ActiveIngredient[];
|
actives?: ActiveIngredient[];
|
||||||
recommended_for: SkinType[];
|
recommended_for: SkinType[];
|
||||||
recommended_frequency?: UsageFrequency;
|
|
||||||
targets: SkinConcern[];
|
targets: SkinConcern[];
|
||||||
contraindications: string[];
|
contraindications: string[];
|
||||||
usage_notes?: string;
|
usage_notes?: string;
|
||||||
evidence_level?: EvidenceLevel;
|
|
||||||
claims: string[];
|
|
||||||
fragrance_free?: boolean;
|
fragrance_free?: boolean;
|
||||||
essential_oils_free?: boolean;
|
essential_oils_free?: boolean;
|
||||||
alcohol_denat_free?: boolean;
|
alcohol_denat_free?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<span class="text-muted-foreground">Brand</span><span>{product.brand}</span>
|
<span class="text-muted-foreground">Brand</span><span>{product.brand}</span>
|
||||||
<span class="text-muted-foreground">Line</span><span>{product.line_name ?? '—'}</span>
|
<span class="text-muted-foreground">Line</span><span>{product.line_name ?? '—'}</span>
|
||||||
<span class="text-muted-foreground">Routine role</span><span>{product.routine_role.replace(/_/g, ' ')}</span>
|
|
||||||
<span class="text-muted-foreground">Time</span><span class="uppercase">{product.recommended_time}</span>
|
<span class="text-muted-foreground">Time</span><span class="uppercase">{product.recommended_time}</span>
|
||||||
<span class="text-muted-foreground">Leave-on</span><span>{product.leave_on ? 'Yes' : 'No'}</span>
|
<span class="text-muted-foreground">Leave-on</span><span>{product.leave_on ? 'Yes' : 'No'}</span>
|
||||||
<span class="text-muted-foreground">Texture</span><span>{product.texture ?? '—'}</span>
|
<span class="text-muted-foreground">Texture</span><span>{product.texture ?? '—'}</span>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,29 @@ export const load: PageServerLoad = async () => {
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function parseOptionalFloat(v: string | null): number | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return isNaN(n) ? undefined : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalInt(v: string | null): number | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return isNaN(n) ? undefined : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTristate(v: string | null): boolean | undefined {
|
||||||
|
if (v === 'true') return true;
|
||||||
|
if (v === 'false') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalString(v: string | null): string | undefined {
|
||||||
|
const s = v?.trim();
|
||||||
|
return s || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request }) => {
|
default: async ({ request }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|
@ -14,29 +37,110 @@ export const actions: Actions = {
|
||||||
const brand = form.get('brand') as string;
|
const brand = form.get('brand') as string;
|
||||||
const category = form.get('category') as string;
|
const category = form.get('category') as string;
|
||||||
const recommended_time = form.get('recommended_time') as string;
|
const recommended_time = form.get('recommended_time') as string;
|
||||||
const routine_role = form.get('routine_role') as string;
|
|
||||||
const leave_on = form.get('leave_on') === 'true';
|
|
||||||
|
|
||||||
if (!name || !brand || !category || !recommended_time || !routine_role) {
|
if (!name || !brand || !category || !recommended_time) {
|
||||||
return fail(400, { error: 'Required fields missing' });
|
return fail(400, { error: 'Required fields missing' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = (form.get('targets') as string)
|
const leave_on = form.get('leave_on') === 'true';
|
||||||
?.split(',')
|
|
||||||
.map((t) => t.trim())
|
// Lists from checkboxes
|
||||||
.filter(Boolean) ?? [];
|
const recommended_for = form.getAll('recommended_for') as string[];
|
||||||
|
const targets = form.getAll('targets') as string[];
|
||||||
|
|
||||||
|
// INCI: split on newlines and commas
|
||||||
|
const inci_raw = form.get('inci') as string;
|
||||||
|
const inci = inci_raw
|
||||||
|
? inci_raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
recommended_time,
|
||||||
|
leave_on,
|
||||||
|
recommended_for,
|
||||||
|
targets,
|
||||||
|
inci
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional strings
|
||||||
|
const optStrings: Array<[string, string]> = [
|
||||||
|
['line_name', 'line_name'],
|
||||||
|
['url', 'url'],
|
||||||
|
['sku', 'sku'],
|
||||||
|
['barcode', 'barcode'],
|
||||||
|
['usage_notes', 'usage_notes'],
|
||||||
|
['personal_tolerance_notes', 'personal_tolerance_notes']
|
||||||
|
];
|
||||||
|
for (const [field, key] of optStrings) {
|
||||||
|
const v = parseOptionalString(form.get(field) as string | null);
|
||||||
|
if (v !== undefined) payload[key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional enum selects (non-empty string = value chosen)
|
||||||
|
const optEnums: Array<[string, string]> = [
|
||||||
|
['texture', 'texture'],
|
||||||
|
['absorption_speed', 'absorption_speed'],
|
||||||
|
['price_tier', 'price_tier'],
|
||||||
|
];
|
||||||
|
for (const [field, key] of optEnums) {
|
||||||
|
const v = form.get(field) as string | null;
|
||||||
|
if (v) payload[key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional numbers
|
||||||
|
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
|
||||||
|
if (size_ml !== undefined) payload.size_ml = size_ml;
|
||||||
|
|
||||||
|
const pao_months = parseOptionalInt(form.get('pao_months') as string | null);
|
||||||
|
if (pao_months !== undefined) payload.pao_months = pao_months;
|
||||||
|
|
||||||
|
const ph_min = parseOptionalFloat(form.get('ph_min') as string | null);
|
||||||
|
if (ph_min !== undefined) payload.ph_min = ph_min;
|
||||||
|
|
||||||
|
const ph_max = parseOptionalFloat(form.get('ph_max') as string | null);
|
||||||
|
if (ph_max !== undefined) payload.ph_max = ph_max;
|
||||||
|
|
||||||
|
const min_interval_hours = parseOptionalInt(form.get('min_interval_hours') as string | null);
|
||||||
|
if (min_interval_hours !== undefined) payload.min_interval_hours = min_interval_hours;
|
||||||
|
|
||||||
|
const max_frequency_per_week = parseOptionalInt(form.get('max_frequency_per_week') as string | null);
|
||||||
|
if (max_frequency_per_week !== undefined) payload.max_frequency_per_week = max_frequency_per_week;
|
||||||
|
|
||||||
|
const personal_rating = parseOptionalInt(form.get('personal_rating') as string | null);
|
||||||
|
if (personal_rating !== undefined) payload.personal_rating = personal_rating;
|
||||||
|
|
||||||
|
const needle_length_mm = parseOptionalFloat(form.get('needle_length_mm') as string | null);
|
||||||
|
if (needle_length_mm !== undefined) payload.needle_length_mm = needle_length_mm;
|
||||||
|
|
||||||
|
// Booleans from checkboxes (unchecked = not sent = false)
|
||||||
|
payload.is_medication = form.get('is_medication') === 'true';
|
||||||
|
payload.is_tool = form.get('is_tool') === 'true';
|
||||||
|
|
||||||
|
// Nullable booleans (tristate)
|
||||||
|
const fragranceFree = parseTristate(form.get('fragrance_free') as string | null);
|
||||||
|
if (fragranceFree !== undefined) payload.fragrance_free = fragranceFree;
|
||||||
|
|
||||||
|
const essentialOilsFree = parseTristate(form.get('essential_oils_free') as string | null);
|
||||||
|
if (essentialOilsFree !== undefined) payload.essential_oils_free = essentialOilsFree;
|
||||||
|
|
||||||
|
const alcoholDenatFree = parseTristate(form.get('alcohol_denat_free') as string | null);
|
||||||
|
if (alcoholDenatFree !== undefined) payload.alcohol_denat_free = alcoholDenatFree;
|
||||||
|
|
||||||
|
const pregnancySafe = parseTristate(form.get('pregnancy_safe') as string | null);
|
||||||
|
if (pregnancySafe !== undefined) payload.pregnancy_safe = pregnancySafe;
|
||||||
|
|
||||||
|
const personalRepurchaseIntent = parseTristate(
|
||||||
|
form.get('personal_repurchase_intent') as string | null
|
||||||
|
);
|
||||||
|
if (personalRepurchaseIntent !== undefined)
|
||||||
|
payload.personal_repurchase_intent = personalRepurchaseIntent;
|
||||||
|
|
||||||
let product;
|
let product;
|
||||||
try {
|
try {
|
||||||
product = await createProduct({
|
product = await createProduct(payload);
|
||||||
name,
|
|
||||||
brand,
|
|
||||||
category,
|
|
||||||
recommended_time,
|
|
||||||
routine_role,
|
|
||||||
leave_on,
|
|
||||||
targets
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(500, { error: (e as Error).message });
|
return fail(500, { error: (e as Error).message });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,47 @@
|
||||||
|
|
||||||
let { form }: { form: ActionData } = $props();
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
||||||
|
// ── enum options ──────────────────────────────────────────────────────────
|
||||||
const categories = [
|
const categories = [
|
||||||
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
||||||
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
|
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
|
||||||
];
|
];
|
||||||
const routineRoles = [
|
const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid'];
|
||||||
'cleanse', 'prepare', 'treatment_active', 'treatment_support',
|
const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow'];
|
||||||
'seal', 'protect', 'hair_treatment'
|
const priceTiers = ['budget', 'mid', 'premium', 'luxury'];
|
||||||
];
|
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||||
const skinConcerns = [
|
const skinConcerns = [
|
||||||
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
|
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
|
||||||
'redness', 'damaged_barrier', 'pore_visibility', 'uneven_texture',
|
'redness', 'damaged_barrier', 'pore_visibility', 'uneven_texture',
|
||||||
'hair_growth', 'sebum_excess'
|
'hair_growth', 'sebum_excess'
|
||||||
];
|
];
|
||||||
|
const tristateOptions = [
|
||||||
|
{ value: '', label: 'Unknown' },
|
||||||
|
{ value: 'true', label: 'Yes' },
|
||||||
|
{ value: 'false', label: 'No' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── controlled select state ───────────────────────────────────────────────
|
||||||
let category = $state('');
|
let category = $state('');
|
||||||
let routineRole = $state('');
|
|
||||||
let recommendedTime = $state('');
|
let recommendedTime = $state('');
|
||||||
let leaveOn = $state('true');
|
let leaveOn = $state('true');
|
||||||
|
let texture = $state('');
|
||||||
|
let absorptionSpeed = $state('');
|
||||||
|
let priceTier = $state('');
|
||||||
|
let fragranceFree = $state('');
|
||||||
|
let essentialOilsFree = $state('');
|
||||||
|
let alcoholDenatFree = $state('');
|
||||||
|
let pregnancySafe = $state('');
|
||||||
|
let personalRepurchaseIntent = $state('');
|
||||||
|
|
||||||
|
function label(val: string) {
|
||||||
|
return val.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>New Product — innercontext</title></svelte:head>
|
<svelte:head><title>New Product — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="max-w-xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/products" class="text-sm text-muted-foreground hover:underline">← Products</a>
|
<a href="/products" class="text-sm text-muted-foreground hover:underline">← Products</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">New Product</h2>
|
<h2 class="text-2xl font-bold tracking-tight">New Product</h2>
|
||||||
|
|
@ -43,85 +61,348 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Card>
|
<form method="POST" use:enhance class="space-y-6">
|
||||||
<CardHeader><CardTitle>Product details</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
<!-- ── Basic info ─────────────────────────────────────────────────── -->
|
||||||
<form method="POST" use:enhance class="space-y-5">
|
<Card>
|
||||||
<div class="space-y-2">
|
<CardHeader><CardTitle>Basic info</CardTitle></CardHeader>
|
||||||
<Label for="name">Name *</Label>
|
<CardContent class="space-y-4">
|
||||||
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" />
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">Name *</Label>
|
||||||
|
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="brand">Brand *</Label>
|
||||||
|
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<Label for="brand">Brand *</Label>
|
<div class="space-y-2">
|
||||||
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" />
|
<Label for="line_name">Line / series</Label>
|
||||||
|
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="url">URL</Label>
|
||||||
|
<Input id="url" name="url" type="url" placeholder="https://…" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="sku">SKU</Label>
|
||||||
|
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="barcode">Barcode / EAN</Label>
|
||||||
|
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Classification ─────────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Classification</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Category *</Label>
|
<Label>Category *</Label>
|
||||||
<input type="hidden" name="category" value={category} />
|
<input type="hidden" name="category" value={category} />
|
||||||
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
||||||
<SelectTrigger>{category ? category.replace(/_/g, ' ') : 'Select category'}</SelectTrigger>
|
<SelectTrigger>{category ? label(category) : 'Select category'}</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each categories as cat}
|
{#each categories as cat}
|
||||||
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
|
<SelectItem value={cat}>{label(cat)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Time *</Label>
|
||||||
|
<input type="hidden" name="recommended_time" value={recommendedTime} />
|
||||||
|
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
|
||||||
|
<SelectTrigger>{recommendedTime ? recommendedTime.toUpperCase() : 'AM / PM / Both'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="am">AM</SelectItem>
|
||||||
|
<SelectItem value="pm">PM</SelectItem>
|
||||||
|
<SelectItem value="both">Both</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Leave-on *</Label>
|
||||||
|
<input type="hidden" name="leave_on" value={leaveOn} />
|
||||||
|
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
|
||||||
|
<SelectTrigger>{leaveOn === 'true' ? 'Yes (leave-on)' : 'No (rinse-off)'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Yes (leave-on)</SelectItem>
|
||||||
|
<SelectItem value="false">No (rinse-off)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Texture</Label>
|
||||||
|
<input type="hidden" name="texture" value={texture} />
|
||||||
|
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
||||||
|
<SelectTrigger>{texture ? label(texture) : 'Select texture'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each textures as t}
|
||||||
|
<SelectItem value={t}>{label(t)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Routine role *</Label>
|
<Label>Absorption speed</Label>
|
||||||
<input type="hidden" name="routine_role" value={routineRole} />
|
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
|
||||||
<Select type="single" value={routineRole} onValueChange={(v) => (routineRole = v)}>
|
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
|
||||||
<SelectTrigger>{routineRole ? routineRole.replace(/_/g, ' ') : 'Select role'}</SelectTrigger>
|
<SelectTrigger>{absorptionSpeed ? label(absorptionSpeed) : 'Select speed'}</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each routineRoles as role}
|
{#each absorptionSpeeds as s}
|
||||||
<SelectItem value={role}>{role.replace(/_/g, ' ')}</SelectItem>
|
<SelectItem value={s}>{label(s)}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Skin profile ───────────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Skin profile</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Recommended time *</Label>
|
<Label>Recommended for skin types</Label>
|
||||||
<input type="hidden" name="recommended_time" value={recommendedTime} />
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
|
{#each skinTypes as st}
|
||||||
<SelectTrigger>{recommendedTime ? recommendedTime.toUpperCase() : 'AM / PM / Both'}</SelectTrigger>
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<SelectContent>
|
<input type="checkbox" name="recommended_for" value={st} class="rounded border-input" />
|
||||||
<SelectItem value="am">AM</SelectItem>
|
{label(st)}
|
||||||
<SelectItem value="pm">PM</SelectItem>
|
</label>
|
||||||
<SelectItem value="both">Both</SelectItem>
|
{/each}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Leave-on?</Label>
|
<Label>Target concerns</Label>
|
||||||
<input type="hidden" name="leave_on" value={leaveOn} />
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
|
{#each skinConcerns as sc}
|
||||||
<SelectTrigger>{leaveOn === 'true' ? 'Yes (leave-on)' : 'No (rinse-off)'}</SelectTrigger>
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<SelectContent>
|
<input type="checkbox" name="targets" value={sc} class="rounded border-input" />
|
||||||
<SelectItem value="true">Yes (leave-on)</SelectItem>
|
{label(sc)}
|
||||||
<SelectItem value="false">No (rinse-off)</SelectItem>
|
</label>
|
||||||
</SelectContent>
|
{/each}
|
||||||
</Select>
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Product details ────────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Product details</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Price tier</Label>
|
||||||
|
<input type="hidden" name="price_tier" value={priceTier} />
|
||||||
|
<Select type="single" value={priceTier} onValueChange={(v) => (priceTier = v)}>
|
||||||
|
<SelectTrigger>{priceTier ? label(priceTier) : 'Select tier'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each priceTiers as p}
|
||||||
|
<SelectItem value={p}>{label(p)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="size_ml">Size (ml)</Label>
|
||||||
|
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="pao_months">PAO (months)</Label>
|
||||||
|
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder="e.g. 12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="ph_min">pH min</Label>
|
||||||
|
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="ph_max">pH max</Label>
|
||||||
|
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder="e.g. 4.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Safety flags ───────────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Safety flags</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Fragrance-free</Label>
|
||||||
|
<input type="hidden" name="fragrance_free" value={fragranceFree} />
|
||||||
|
<Select type="single" value={fragranceFree} onValueChange={(v) => (fragranceFree = v)}>
|
||||||
|
<SelectTrigger>{fragranceFree === '' ? 'Unknown' : fragranceFree === 'true' ? 'Yes' : 'No'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each tristateOptions as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Essential oils-free</Label>
|
||||||
|
<input type="hidden" name="essential_oils_free" value={essentialOilsFree} />
|
||||||
|
<Select type="single" value={essentialOilsFree} onValueChange={(v) => (essentialOilsFree = v)}>
|
||||||
|
<SelectTrigger>{essentialOilsFree === '' ? 'Unknown' : essentialOilsFree === 'true' ? 'Yes' : 'No'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each tristateOptions as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Alcohol denat-free</Label>
|
||||||
|
<input type="hidden" name="alcohol_denat_free" value={alcoholDenatFree} />
|
||||||
|
<Select type="single" value={alcoholDenatFree} onValueChange={(v) => (alcoholDenatFree = v)}>
|
||||||
|
<SelectTrigger>{alcoholDenatFree === '' ? 'Unknown' : alcoholDenatFree === 'true' ? 'Yes' : 'No'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each tristateOptions as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Pregnancy safe</Label>
|
||||||
|
<input type="hidden" name="pregnancy_safe" value={pregnancySafe} />
|
||||||
|
<Select type="single" value={pregnancySafe} onValueChange={(v) => (pregnancySafe = v)}>
|
||||||
|
<SelectTrigger>{pregnancySafe === '' ? 'Unknown' : pregnancySafe === 'true' ? 'Yes' : 'No'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each tristateOptions as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Ingredients & notes ────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Ingredients & notes</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="inci">INCI list (one ingredient per line)</Label>
|
||||||
|
<textarea
|
||||||
|
id="inci"
|
||||||
|
name="inci"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Aqua Glycerin Niacinamide"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="targets">Target concerns (comma-separated)</Label>
|
<Label for="usage_notes">Usage notes</Label>
|
||||||
<Input
|
<textarea
|
||||||
id="targets"
|
id="usage_notes"
|
||||||
name="targets"
|
name="usage_notes"
|
||||||
placeholder={skinConcerns.slice(0, 3).join(', ')}
|
rows="2"
|
||||||
/>
|
placeholder="e.g. Apply to damp skin, avoid eye area"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Usage constraints ──────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Usage constraints</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="min_interval_hours">Min interval (hours)</Label>
|
||||||
|
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="max_frequency_per_week">Max uses per week</Label>
|
||||||
|
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder="e.g. 3" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-6">
|
||||||
<Button type="submit">Create product</Button>
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Button variant="outline" href="/products">Cancel</Button>
|
<input type="checkbox" name="is_medication" value="true" class="rounded border-input" />
|
||||||
|
Is medication
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" name="is_tool" value="true" class="rounded border-input" />
|
||||||
|
Is tool (e.g. dermaroller)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</CardContent>
|
<div class="space-y-2">
|
||||||
</Card>
|
<Label for="needle_length_mm">Needle length (mm, tools only)</Label>
|
||||||
|
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ── Personal ───────────────────────────────────────────────────── -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Personal notes</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="personal_rating">Personal rating (1–10)</Label>
|
||||||
|
<Input id="personal_rating" name="personal_rating" type="number" min="1" max="10" placeholder="e.g. 8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Repurchase intent</Label>
|
||||||
|
<input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} />
|
||||||
|
<Select type="single" value={personalRepurchaseIntent} onValueChange={(v) => (personalRepurchaseIntent = v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
{personalRepurchaseIntent === '' ? 'Unknown' : personalRepurchaseIntent === 'true' ? 'Yes' : 'No'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each tristateOptions as opt}
|
||||||
|
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="personal_tolerance_notes">Tolerance notes</Label>
|
||||||
|
<textarea
|
||||||
|
id="personal_tolerance_notes"
|
||||||
|
name="personal_tolerance_notes"
|
||||||
|
rows="2"
|
||||||
|
placeholder="e.g. Causes mild stinging, fine after 2 weeks"
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pb-6">
|
||||||
|
<Button type="submit">Create product</Button>
|
||||||
|
<Button variant="outline" href="/products">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue