feat: complete product create/edit form with all fields

Extract shared ProductForm.svelte component covering actives (dynamic
rows with ingredient function checkboxes), product_effect_profile
(range sliders 0–5), context_rules (tristate selects), synergizes_with,
incompatible_with (dynamic rows), and contraindications.

Replace the Quick edit stub on the product detail page with the full
edit form pre-populated from the existing product. Update both server
actions to parse all new fields including JSON payloads for complex
nested objects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-27 11:43:56 +01:00
parent 6333c6678a
commit 479be25112
5 changed files with 1165 additions and 481 deletions

View file

@ -0,0 +1,901 @@
<script lang="ts">
import type { Product } from '$lib/types';
import type { IngredientFunction, InteractionScope } from '$lib/types';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { product }: { product?: Product } = $props();
function lbl(val: string) {
return val.replace(/_/g, ' ');
}
// ── Enum option lists ─────────────────────────────────────────────────────
const categories = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
];
const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid'];
const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow'];
const priceTiers = ['budget', 'mid', 'premium', 'luxury'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const skinConcerns = [
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
'redness', 'damaged_barrier', 'pore_visibility', 'uneven_texture',
'hair_growth', 'sebum_excess'
];
const ingFunctions: IngredientFunction[] = [
'humectant', 'emollient', 'occlusive',
'exfoliant_aha', 'exfoliant_bha', 'exfoliant_pha',
'retinoid', 'antioxidant', 'soothing', 'barrier_support',
'brightening', 'anti_acne', 'ceramide', 'niacinamide',
'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c'
];
const interactionScopes: InteractionScope[] = ['same_step', 'same_day', 'same_period'];
const tristate = [
{ value: '', label: 'Unknown' },
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' }
];
const effectFields = [
{ key: 'hydration_immediate', label: 'Hydration (immediate)' },
{ key: 'hydration_long_term', label: 'Hydration (long term)' },
{ key: 'barrier_repair_strength', label: 'Barrier repair' },
{ key: 'soothing_strength', label: 'Soothing' },
{ key: 'exfoliation_strength', label: 'Exfoliation' },
{ key: 'retinoid_strength', label: 'Retinoid activity' },
{ key: 'irritation_risk', label: 'Irritation risk' },
{ key: 'comedogenic_risk', label: 'Comedogenic risk' },
{ key: 'barrier_disruption_risk', label: 'Barrier disruption risk' },
{ key: 'dryness_risk', label: 'Dryness risk' },
{ key: 'brightening_strength', label: 'Brightening' },
{ key: 'anti_acne_strength', label: 'Anti-acne' },
{ key: 'anti_aging_strength', label: 'Anti-aging' }
] as const;
// ── Select reactive state ─────────────────────────────────────────────────
let category = $state(product?.category ?? '');
let recommendedTime = $state(product?.recommended_time ?? '');
let leaveOn = $state(product?.leave_on != null ? String(product.leave_on) : 'true');
let texture = $state(product?.texture ?? '');
let absorptionSpeed = $state(product?.absorption_speed ?? '');
let priceTier = $state(product?.price_tier ?? '');
let fragranceFree = $state(
product?.fragrance_free != null ? String(product.fragrance_free) : ''
);
let essentialOilsFree = $state(
product?.essential_oils_free != null ? String(product.essential_oils_free) : ''
);
let alcoholDenatFree = $state(
product?.alcohol_denat_free != null ? String(product.alcohol_denat_free) : ''
);
let pregnancySafe = $state(
product?.pregnancy_safe != null ? String(product.pregnancy_safe) : ''
);
let personalRepurchaseIntent = $state(
product?.personal_repurchase_intent != null ? String(product.personal_repurchase_intent) : ''
);
// context rules tristate
const cr = product?.context_rules;
let ctxAfterShaving = $state(
cr?.safe_after_shaving != null ? String(cr.safe_after_shaving) : ''
);
let ctxAfterAcids = $state(cr?.safe_after_acids != null ? String(cr.safe_after_acids) : '');
let ctxAfterRetinoids = $state(
cr?.safe_after_retinoids != null ? String(cr.safe_after_retinoids) : ''
);
let ctxCompromisedBarrier = $state(
cr?.safe_with_compromised_barrier != null ? String(cr.safe_with_compromised_barrier) : ''
);
let ctxLowUvOnly = $state(cr?.low_uv_only != null ? String(cr.low_uv_only) : '');
// ── Effect profile ────────────────────────────────────────────────────────
const ep = product?.product_effect_profile;
let effectValues = $state({
hydration_immediate: ep?.hydration_immediate ?? 0,
hydration_long_term: ep?.hydration_long_term ?? 0,
barrier_repair_strength: ep?.barrier_repair_strength ?? 0,
soothing_strength: ep?.soothing_strength ?? 0,
exfoliation_strength: ep?.exfoliation_strength ?? 0,
retinoid_strength: ep?.retinoid_strength ?? 0,
irritation_risk: ep?.irritation_risk ?? 0,
comedogenic_risk: ep?.comedogenic_risk ?? 0,
barrier_disruption_risk: ep?.barrier_disruption_risk ?? 0,
dryness_risk: ep?.dryness_risk ?? 0,
brightening_strength: ep?.brightening_strength ?? 0,
anti_acne_strength: ep?.anti_acne_strength ?? 0,
anti_aging_strength: ep?.anti_aging_strength ?? 0
});
// ── Dynamic actives ───────────────────────────────────────────────────────
type ActiveRow = {
name: string;
percent: string;
functions: IngredientFunction[];
strength_level: string;
irritation_potential: string;
};
let actives: ActiveRow[] = $state(
product?.actives?.map((a) => ({
name: a.name,
percent: a.percent != null ? String(a.percent) : '',
functions: [...(a.functions ?? [])] as IngredientFunction[],
strength_level: a.strength_level != null ? String(a.strength_level) : '',
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
})) ?? []
);
function addActive() {
actives = [
...actives,
{ name: '', percent: '', functions: [], strength_level: '', irritation_potential: '' }
];
}
function removeActive(i: number) {
actives = actives.filter((_, idx) => idx !== i);
}
function toggleFn(i: number, fn: IngredientFunction) {
const cur = actives[i].functions;
actives[i].functions = cur.includes(fn) ? cur.filter((f) => f !== fn) : [...cur, fn];
}
let activesJson = $derived(
JSON.stringify(
actives
.filter((a) => a.name.trim())
.map((a) => ({
name: a.name.trim(),
...(a.percent ? { percent: parseFloat(a.percent) } : {}),
functions: a.functions,
...(a.strength_level ? { strength_level: parseInt(a.strength_level) } : {}),
...(a.irritation_potential
? { irritation_potential: parseInt(a.irritation_potential) }
: {})
}))
)
);
// ── Dynamic incompatible_with ─────────────────────────────────────────────
type IncompatibleRow = { target: string; scope: string; reason: string };
let incompatibleWith: IncompatibleRow[] = $state(
product?.incompatible_with?.map((i) => ({
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
})) ?? []
);
function addIncompatible() {
incompatibleWith = [...incompatibleWith, { target: '', scope: '', reason: '' }];
}
function removeIncompatible(i: number) {
incompatibleWith = incompatibleWith.filter((_, idx) => idx !== i);
}
let incompatibleJson = $derived(
JSON.stringify(
incompatibleWith
.filter((i) => i.target.trim() && i.scope)
.map((i) => ({
target: i.target.trim(),
scope: i.scope,
...(i.reason.trim() ? { reason: i.reason.trim() } : {})
}))
)
);
const textareaClass =
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
const selectClass =
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Basic info</CardTitle></CardHeader>
<CardContent class="space-y-4">
<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" value={product?.name ?? ''} />
</div>
<div class="space-y-2">
<Label for="brand">Brand *</Label>
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" value={product?.brand ?? ''} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">Line / series</Label>
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" value={product?.line_name ?? ''} />
</div>
<div class="space-y-2">
<Label for="url">URL</Label>
<Input id="url" name="url" type="url" placeholder="https://…" value={product?.url ?? ''} />
</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" value={product?.sku ?? ''} />
</div>
<div class="space-y-2">
<Label for="barcode">Barcode / EAN</Label>
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" value={product?.barcode ?? ''} />
</div>
</div>
</CardContent>
</Card>
<!-- ── Classification ────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Classification</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>Category *</Label>
<input type="hidden" name="category" value={category} />
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
<SelectTrigger>{category ? lbl(category) : 'Select category'}</SelectTrigger>
<SelectContent>
{#each categories as cat}
<SelectItem value={cat}>{lbl(cat)}</SelectItem>
{/each}
</SelectContent>
</Select>
</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 ? lbl(texture) : 'Select texture'}</SelectTrigger>
<SelectContent>
{#each textures as t}
<SelectItem value={t}>{lbl(t)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-2">
<Label>Absorption speed</Label>
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
<SelectTrigger>{absorptionSpeed ? lbl(absorptionSpeed) : 'Select speed'}</SelectTrigger>
<SelectContent>
{#each absorptionSpeeds as s}
<SelectItem value={s}>{lbl(s)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Skin profile</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>Recommended for skin types</Label>
<div class="grid grid-cols-3 gap-2">
{#each skinTypes as st}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="recommended_for"
value={st}
checked={product?.recommended_for?.includes(st as never) ?? false}
class="rounded border-input"
/>
{lbl(st)}
</label>
{/each}
</div>
</div>
<div class="space-y-2">
<Label>Target concerns</Label>
<div class="grid grid-cols-3 gap-2">
{#each skinConcerns as sc}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="targets"
value={sc}
checked={product?.targets?.includes(sc as never) ?? false}
class="rounded border-input"
/>
{lbl(sc)}
</label>
{/each}
</div>
</div>
<div class="space-y-2">
<Label for="contraindications">Contraindications (one per line)</Label>
<textarea
id="contraindications"
name="contraindications"
rows="2"
placeholder="e.g. active rosacea flares"
class={textareaClass}
>{product?.contraindications?.join('\n') ?? ''}</textarea>
</div>
</CardContent>
</Card>
<!-- ── Ingredients ────────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Ingredients</CardTitle></CardHeader>
<CardContent class="space-y-6">
<div class="space-y-2">
<Label for="inci">INCI list (one ingredient per line)</Label>
<textarea
id="inci"
name="inci"
rows="5"
placeholder="Aqua&#10;Glycerin&#10;Niacinamide"
class={textareaClass}
>{product?.inci?.join('\n') ?? ''}</textarea>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label>Active ingredients</Label>
<Button type="button" variant="outline" size="sm" onclick={addActive}>+ Add active</Button>
</div>
<input type="hidden" name="actives_json" value={activesJson} />
{#each actives as active, i}
<div class="rounded-md border border-border p-3 space-y-3">
<div class="grid grid-cols-[1fr_100px_120px_120px_auto] gap-2 items-end">
<div class="space-y-1">
<Label class="text-xs">Name</Label>
<Input
placeholder="e.g. Niacinamide"
bind:value={active.name}
/>
</div>
<div class="space-y-1">
<Label class="text-xs">%</Label>
<Input
type="number"
min="0"
max="100"
step="0.01"
placeholder="e.g. 5"
bind:value={active.percent}
/>
</div>
<div class="space-y-1">
<Label class="text-xs">Strength</Label>
<select class={selectClass} bind:value={active.strength_level}>
<option value=""></option>
<option value="1">1 Low</option>
<option value="2">2 Medium</option>
<option value="3">3 High</option>
</select>
</div>
<div class="space-y-1">
<Label class="text-xs">Irritation</Label>
<select class={selectClass} bind:value={active.irritation_potential}>
<option value=""></option>
<option value="1">1 Low</option>
<option value="2">2 Medium</option>
<option value="3">3 High</option>
</select>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">Functions</Label>
<div class="grid grid-cols-4 gap-1">
{#each ingFunctions as fn}
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input
type="checkbox"
checked={active.functions.includes(fn)}
onchange={() => toggleFn(i, fn)}
class="rounded border-input"
/>
{lbl(fn)}
</label>
{/each}
</div>
</div>
</div>
{/each}
{#if actives.length === 0}
<p class="text-sm text-muted-foreground">No actives added yet.</p>
{/if}
</div>
</CardContent>
</Card>
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Effect profile (05)</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each effectFields as field}
{@const key = field.key as keyof typeof effectValues}
<div class="flex items-center gap-3">
<span class="w-40 shrink-0 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="flex-1 accent-primary"
/>
<span class="w-4 text-center font-mono text-sm">{effectValues[key]}</span>
</div>
{/each}
</div>
</CardContent>
</Card>
<!-- ── Interactions ───────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Interactions</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="synergizes_with">Synergizes with (one per line)</Label>
<textarea
id="synergizes_with"
name="synergizes_with"
rows="3"
placeholder="Ceramides&#10;Niacinamide&#10;Retinoids"
class={textareaClass}
>{product?.synergizes_with?.join('\n') ?? ''}</textarea>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label>Incompatible with</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
+ Add incompatibility
</Button>
</div>
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i}
<div class="grid grid-cols-[1fr_140px_1fr_auto] gap-2 items-end">
<div class="space-y-1">
<Label class="text-xs">Target ingredient</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
</div>
<div class="space-y-1">
<Label class="text-xs">Scope</Label>
<select class={selectClass} bind:value={row.scope}>
<option value="">Select…</option>
{#each interactionScopes as s}
<option value={s}>{lbl(s)}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label class="text-xs">Reason (optional)</Label>
<Input placeholder="e.g. reduces efficacy" bind:value={row.reason} />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeIncompatible(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div>
{/each}
{#if incompatibleWith.length === 0}
<p class="text-sm text-muted-foreground">No incompatibilities added.</p>
{/if}
</div>
</CardContent>
</Card>
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Context rules</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Safe after shaving</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}>
<SelectTrigger>
{ctxAfterShaving === '' ? 'Unknown' : ctxAfterShaving === 'true' ? 'Yes' : 'No'}
</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Safe after acids</Label>
<input type="hidden" name="ctx_safe_after_acids" value={ctxAfterAcids} />
<Select type="single" value={ctxAfterAcids} onValueChange={(v) => (ctxAfterAcids = v)}>
<SelectTrigger>
{ctxAfterAcids === '' ? 'Unknown' : ctxAfterAcids === 'true' ? 'Yes' : 'No'}
</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Safe after retinoids</Label>
<input type="hidden" name="ctx_safe_after_retinoids" value={ctxAfterRetinoids} />
<Select
type="single"
value={ctxAfterRetinoids}
onValueChange={(v) => (ctxAfterRetinoids = v)}
>
<SelectTrigger>
{ctxAfterRetinoids === '' ? 'Unknown' : ctxAfterRetinoids === 'true' ? 'Yes' : 'No'}
</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Safe with compromised barrier</Label>
<input type="hidden" name="ctx_safe_with_compromised_barrier" value={ctxCompromisedBarrier} />
<Select
type="single"
value={ctxCompromisedBarrier}
onValueChange={(v) => (ctxCompromisedBarrier = v)}
>
<SelectTrigger>
{ctxCompromisedBarrier === ''
? 'Unknown'
: ctxCompromisedBarrier === 'true'
? 'Yes'
: 'No'}
</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Low UV only (evening/covered)</Label>
<input type="hidden" name="ctx_low_uv_only" value={ctxLowUvOnly} />
<Select type="single" value={ctxLowUvOnly} onValueChange={(v) => (ctxLowUvOnly = v)}>
<SelectTrigger>
{ctxLowUvOnly === '' ? 'Unknown' : ctxLowUvOnly === 'true' ? 'Yes' : 'No'}
</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<!-- ── Product details ────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>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 ? lbl(priceTier) : 'Select tier'}</SelectTrigger>
<SelectContent>
{#each priceTiers as p}
<SelectItem value={p}>{lbl(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"
value={product?.size_ml ?? ''}
/>
</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"
value={product?.pao_months ?? ''}
/>
</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"
value={product?.ph_min ?? ''}
/>
</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"
value={product?.ph_max ?? ''}
/>
</div>
</div>
<div class="space-y-2">
<Label for="usage_notes">Usage notes</Label>
<textarea
id="usage_notes"
name="usage_notes"
rows="2"
placeholder="e.g. Apply to damp skin, avoid eye area"
class={textareaClass}
>{product?.usage_notes ?? ''}</textarea>
</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 tristate 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 tristate 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 tristate 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 tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</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"
value={product?.min_interval_hours ?? ''}
/>
</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"
value={product?.max_frequency_per_week ?? ''}
/>
</div>
</div>
<div class="flex gap-6">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="is_medication"
value="true"
checked={product?.is_medication ?? false}
class="rounded border-input"
/>
Is medication
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="is_tool"
value="true"
checked={product?.is_tool ?? false}
class="rounded border-input"
/>
Is tool (e.g. dermaroller)
</label>
</div>
<div class="space-y-2">
<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"
value={product?.needle_length_mm ?? ''}
/>
</div>
</CardContent>
</Card>
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Personal notes</CardTitle></CardHeader>
<CardContent class="space-y-4">
<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 tristate as opt}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</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={textareaClass}
>{product?.personal_tolerance_notes ?? ''}</textarea>
</div>
</CardContent>
</Card>