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:
parent
6333c6678a
commit
479be25112
5 changed files with 1165 additions and 481 deletions
901
frontend/src/lib/components/ProductForm.svelte
Normal file
901
frontend/src/lib/components/ProductForm.svelte
Normal 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 Glycerin 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 (0–5)</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 Niacinamide 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>
|
||||||
|
|
@ -11,15 +11,173 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTextList(v: string | null): string[] {
|
||||||
|
if (!v?.trim()) return [];
|
||||||
|
return v.split(/\n/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEffectProfile(form: FormData): Record<string, number> {
|
||||||
|
const keys = [
|
||||||
|
'hydration_immediate', 'hydration_long_term',
|
||||||
|
'barrier_repair_strength', 'soothing_strength',
|
||||||
|
'exfoliation_strength', 'retinoid_strength',
|
||||||
|
'irritation_risk', 'comedogenic_risk',
|
||||||
|
'barrier_disruption_risk', 'dryness_risk',
|
||||||
|
'brightening_strength', 'anti_acne_strength', 'anti_aging_strength'
|
||||||
|
];
|
||||||
|
return Object.fromEntries(
|
||||||
|
keys.map((k) => [k, parseOptionalInt(form.get(`effect_${k}`) as string | null) ?? 0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContextRules(
|
||||||
|
form: FormData
|
||||||
|
): Record<string, boolean | undefined> | undefined {
|
||||||
|
const fields: Array<[string, string]> = [
|
||||||
|
['ctx_safe_after_shaving', 'safe_after_shaving'],
|
||||||
|
['ctx_safe_after_acids', 'safe_after_acids'],
|
||||||
|
['ctx_safe_after_retinoids', 'safe_after_retinoids'],
|
||||||
|
['ctx_safe_with_compromised_barrier', 'safe_with_compromised_barrier'],
|
||||||
|
['ctx_low_uv_only', 'low_uv_only']
|
||||||
|
];
|
||||||
|
const result: Record<string, boolean | undefined> = {};
|
||||||
|
let hasAny = false;
|
||||||
|
for (const [field, key] of fields) {
|
||||||
|
const v = parseTristate(form.get(field) as string | null);
|
||||||
|
if (v !== undefined) {
|
||||||
|
result[key] = v;
|
||||||
|
hasAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasAny ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
update: async ({ params, request }) => {
|
update: async ({ params, request }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const body: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of form.entries()) {
|
const name = form.get('name') as string;
|
||||||
if (value !== '') body[key] = value;
|
const brand = form.get('brand') as string;
|
||||||
|
const category = form.get('category') as string;
|
||||||
|
const recommended_time = form.get('recommended_time') as string;
|
||||||
|
|
||||||
|
if (!name || !brand || !category || !recommended_time) {
|
||||||
|
return fail(400, { error: 'Required fields missing' });
|
||||||
}
|
}
|
||||||
if ('leave_on' in body) body.leave_on = body.leave_on === 'true';
|
|
||||||
try {
|
const leave_on = form.get('leave_on') === 'true';
|
||||||
|
const recommended_for = form.getAll('recommended_for') as string[];
|
||||||
|
const targets = form.getAll('targets') as string[];
|
||||||
|
const contraindications = parseTextList(form.get('contraindications') as string | null);
|
||||||
|
|
||||||
|
const inci_raw = form.get('inci') as string;
|
||||||
|
const inci = inci_raw
|
||||||
|
? inci_raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
recommended_time,
|
||||||
|
leave_on,
|
||||||
|
recommended_for,
|
||||||
|
targets,
|
||||||
|
contraindications,
|
||||||
|
inci,
|
||||||
|
product_effect_profile: parseEffectProfile(form)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional strings
|
||||||
|
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
|
||||||
|
const v = parseOptionalString(form.get(field) as string | null);
|
||||||
|
body[field] = v ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional enum selects (null if empty = clearing the value)
|
||||||
|
for (const field of ['texture', 'absorption_speed', 'price_tier']) {
|
||||||
|
const v = form.get(field) as string | null;
|
||||||
|
body[field] = v || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional numbers
|
||||||
|
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null;
|
||||||
|
body.pao_months = parseOptionalInt(form.get('pao_months') as string | null) ?? null;
|
||||||
|
body.ph_min = parseOptionalFloat(form.get('ph_min') as string | null) ?? null;
|
||||||
|
body.ph_max = parseOptionalFloat(form.get('ph_max') as string | null) ?? null;
|
||||||
|
body.min_interval_hours =
|
||||||
|
parseOptionalInt(form.get('min_interval_hours') as string | null) ?? null;
|
||||||
|
body.max_frequency_per_week =
|
||||||
|
parseOptionalInt(form.get('max_frequency_per_week') as string | null) ?? null;
|
||||||
|
body.needle_length_mm =
|
||||||
|
parseOptionalFloat(form.get('needle_length_mm') as string | null) ?? null;
|
||||||
|
|
||||||
|
// Booleans from checkboxes
|
||||||
|
body.is_medication = form.get('is_medication') === 'true';
|
||||||
|
body.is_tool = form.get('is_tool') === 'true';
|
||||||
|
|
||||||
|
// Nullable booleans
|
||||||
|
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) {
|
||||||
|
body[field] = parseTristate(form.get(field) as string | null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actives
|
||||||
|
try {
|
||||||
|
const raw = form.get('actives_json') as string | null;
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
body.actives = Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
|
||||||
|
} else {
|
||||||
|
body.actives = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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
|
||||||
|
body.context_rules = parseContextRules(form) ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
const product = await updateProduct(params.id, body);
|
const product = await updateProduct(params.id, body);
|
||||||
return { success: true, product };
|
return { success: true, product };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import ProductForm from '$lib/components/ProductForm.svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { product } = $derived(data);
|
let { product } = $derived(data);
|
||||||
|
|
@ -30,68 +31,14 @@
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Saved.</div>
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Saved.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Product info -->
|
<!-- Edit form -->
|
||||||
<Card>
|
<form method="POST" action="?/update" use:enhance class="space-y-6">
|
||||||
<CardHeader><CardTitle>Details</CardTitle></CardHeader>
|
<ProductForm {product} />
|
||||||
<CardContent class="space-y-3 text-sm">
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<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">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">Texture</span><span>{product.texture ?? '—'}</span>
|
|
||||||
<span class="text-muted-foreground">pH</span>
|
|
||||||
<span>
|
|
||||||
{#if product.ph_min != null && product.ph_max != null}
|
|
||||||
{product.ph_min}–{product.ph_max}
|
|
||||||
{:else}
|
|
||||||
—
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if product.targets.length}
|
|
||||||
<div>
|
|
||||||
<span class="text-muted-foreground">Targets: </span>
|
|
||||||
{#each product.targets as t}
|
|
||||||
<Badge variant="secondary" class="mr-1">{t.replace(/_/g, ' ')}</Badge>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if product.actives?.length}
|
|
||||||
<div>
|
|
||||||
<p class="text-muted-foreground mb-1">Actives:</p>
|
|
||||||
<ul class="list-disc pl-4 space-y-0.5">
|
|
||||||
{#each product.actives as a}
|
|
||||||
<li>{a.name}{a.percent != null ? ` ${a.percent}%` : ''}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if product.usage_notes}
|
|
||||||
<p class="text-muted-foreground">{product.usage_notes}</p>
|
|
||||||
{/if}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Quick edit -->
|
<div class="flex gap-3">
|
||||||
<Card>
|
<Button type="submit">Save changes</Button>
|
||||||
<CardHeader><CardTitle>Quick edit</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" action="?/update" use:enhance class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="personal_tolerance_notes">Tolerance notes</Label>
|
|
||||||
<Input
|
|
||||||
id="personal_tolerance_notes"
|
|
||||||
name="personal_tolerance_notes"
|
|
||||||
value={product.personal_tolerance_notes ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<Button type="submit" size="sm">Save</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|
@ -136,7 +83,7 @@
|
||||||
{#if product.inventory.length}
|
{#if product.inventory.length}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each product.inventory as pkg}
|
{#each product.inventory as pkg}
|
||||||
<div class="rounded-md border border-border px-4 py-3 text-sm flex items-center justify-between">
|
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3 text-sm">
|
||||||
<div class="space-x-3">
|
<div class="space-x-3">
|
||||||
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
||||||
{pkg.is_opened ? 'Open' : 'Sealed'}
|
{pkg.is_opened ? 'Open' : 'Sealed'}
|
||||||
|
|
@ -163,8 +110,14 @@
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Danger zone -->
|
||||||
<div>
|
<div>
|
||||||
<form method="POST" action="?/delete" use:enhance
|
<form
|
||||||
onsubmit={(e) => { if (!confirm('Delete this product?')) e.preventDefault(); }}>
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => {
|
||||||
|
if (!confirm('Delete this product?')) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button type="submit" variant="destructive" size="sm">Delete product</Button>
|
<Button type="submit" variant="destructive" size="sm">Delete product</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,47 @@ function parseOptionalString(v: string | null): string | undefined {
|
||||||
return s || undefined;
|
return s || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTextList(v: string | null): string[] {
|
||||||
|
if (!v?.trim()) return [];
|
||||||
|
return v.split(/\n/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEffectProfile(form: FormData): Record<string, number> {
|
||||||
|
const keys = [
|
||||||
|
'hydration_immediate', 'hydration_long_term',
|
||||||
|
'barrier_repair_strength', 'soothing_strength',
|
||||||
|
'exfoliation_strength', 'retinoid_strength',
|
||||||
|
'irritation_risk', 'comedogenic_risk',
|
||||||
|
'barrier_disruption_risk', 'dryness_risk',
|
||||||
|
'brightening_strength', 'anti_acne_strength', 'anti_aging_strength'
|
||||||
|
];
|
||||||
|
return Object.fromEntries(
|
||||||
|
keys.map((k) => [k, parseOptionalInt(form.get(`effect_${k}`) as string | null) ?? 0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContextRules(
|
||||||
|
form: FormData
|
||||||
|
): Record<string, boolean | undefined> | undefined {
|
||||||
|
const fields: Array<[string, string]> = [
|
||||||
|
['ctx_safe_after_shaving', 'safe_after_shaving'],
|
||||||
|
['ctx_safe_after_acids', 'safe_after_acids'],
|
||||||
|
['ctx_safe_after_retinoids', 'safe_after_retinoids'],
|
||||||
|
['ctx_safe_with_compromised_barrier', 'safe_with_compromised_barrier'],
|
||||||
|
['ctx_low_uv_only', 'low_uv_only']
|
||||||
|
];
|
||||||
|
const result: Record<string, boolean | undefined> = {};
|
||||||
|
let hasAny = false;
|
||||||
|
for (const [field, key] of fields) {
|
||||||
|
const v = parseTristate(form.get(field) as string | null);
|
||||||
|
if (v !== undefined) {
|
||||||
|
result[key] = v;
|
||||||
|
hasAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasAny ? result : 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();
|
||||||
|
|
@ -43,12 +84,10 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const leave_on = form.get('leave_on') === 'true';
|
const leave_on = form.get('leave_on') === 'true';
|
||||||
|
|
||||||
// Lists from checkboxes
|
|
||||||
const recommended_for = form.getAll('recommended_for') as string[];
|
const recommended_for = form.getAll('recommended_for') as string[];
|
||||||
const targets = form.getAll('targets') as string[];
|
const targets = form.getAll('targets') as string[];
|
||||||
|
const contraindications = parseTextList(form.get('contraindications') as string | null);
|
||||||
|
|
||||||
// INCI: split on newlines and commas
|
|
||||||
const inci_raw = form.get('inci') as string;
|
const inci_raw = form.get('inci') as string;
|
||||||
const inci = inci_raw
|
const inci = inci_raw
|
||||||
? inci_raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
? inci_raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
|
@ -62,32 +101,21 @@ export const actions: Actions = {
|
||||||
leave_on,
|
leave_on,
|
||||||
recommended_for,
|
recommended_for,
|
||||||
targets,
|
targets,
|
||||||
inci
|
contraindications,
|
||||||
|
inci,
|
||||||
|
product_effect_profile: parseEffectProfile(form)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional strings
|
// Optional strings
|
||||||
const optStrings: Array<[string, string]> = [
|
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
|
||||||
['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);
|
const v = parseOptionalString(form.get(field) as string | null);
|
||||||
if (v !== undefined) payload[key] = v;
|
if (v !== undefined) payload[field] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional enum selects (non-empty string = value chosen)
|
// Optional enum selects
|
||||||
const optEnums: Array<[string, string]> = [
|
for (const field of ['texture', 'absorption_speed', 'price_tier']) {
|
||||||
['texture', 'texture'],
|
|
||||||
['absorption_speed', 'absorption_speed'],
|
|
||||||
['price_tier', 'price_tier'],
|
|
||||||
];
|
|
||||||
for (const [field, key] of optEnums) {
|
|
||||||
const v = form.get(field) as string | null;
|
const v = form.get(field) as string | null;
|
||||||
if (v) payload[key] = v;
|
if (v) payload[field] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional numbers
|
// Optional numbers
|
||||||
|
|
@ -106,34 +134,49 @@ export const actions: Actions = {
|
||||||
const min_interval_hours = parseOptionalInt(form.get('min_interval_hours') as string | null);
|
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;
|
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);
|
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;
|
if (max_frequency_per_week !== undefined) payload.max_frequency_per_week = max_frequency_per_week;
|
||||||
|
|
||||||
const needle_length_mm = parseOptionalFloat(form.get('needle_length_mm') as string | null);
|
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;
|
if (needle_length_mm !== undefined) payload.needle_length_mm = needle_length_mm;
|
||||||
|
|
||||||
// Booleans from checkboxes (unchecked = not sent = false)
|
// Booleans from checkboxes
|
||||||
payload.is_medication = form.get('is_medication') === 'true';
|
payload.is_medication = form.get('is_medication') === 'true';
|
||||||
payload.is_tool = form.get('is_tool') === 'true';
|
payload.is_tool = form.get('is_tool') === 'true';
|
||||||
|
|
||||||
// Nullable booleans (tristate)
|
// Nullable booleans
|
||||||
const fragranceFree = parseTristate(form.get('fragrance_free') as string | null);
|
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) {
|
||||||
if (fragranceFree !== undefined) payload.fragrance_free = fragranceFree;
|
const v = parseTristate(form.get(field) as string | null);
|
||||||
|
if (v !== undefined) payload[field] = v;
|
||||||
|
}
|
||||||
|
|
||||||
const essentialOilsFree = parseTristate(form.get('essential_oils_free') as string | null);
|
// Actives (JSON array)
|
||||||
if (essentialOilsFree !== undefined) payload.essential_oils_free = essentialOilsFree;
|
try {
|
||||||
|
const raw = form.get('actives_json') as string | null;
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) payload.actives = parsed;
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed JSON */ }
|
||||||
|
|
||||||
const alcoholDenatFree = parseTristate(form.get('alcohol_denat_free') as string | null);
|
// Incompatible with (JSON array)
|
||||||
if (alcoholDenatFree !== undefined) payload.alcohol_denat_free = alcoholDenatFree;
|
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 */ }
|
||||||
|
|
||||||
const pregnancySafe = parseTristate(form.get('pregnancy_safe') as string | null);
|
// Synergizes with
|
||||||
if (pregnancySafe !== undefined) payload.pregnancy_safe = pregnancySafe;
|
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
|
||||||
|
if (synergizes.length > 0) payload.synergizes_with = synergizes;
|
||||||
|
|
||||||
const personalRepurchaseIntent = parseTristate(
|
// Context rules
|
||||||
form.get('personal_repurchase_intent') as string | null
|
const contextRules = parseContextRules(form);
|
||||||
);
|
if (contextRules) payload.context_rules = contextRules;
|
||||||
if (personalRepurchaseIntent !== undefined)
|
|
||||||
payload.personal_repurchase_intent = personalRepurchaseIntent;
|
|
||||||
|
|
||||||
let product;
|
let product;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,49 +2,9 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { ActionData } from './$types';
|
import type { ActionData } from './$types';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import ProductForm from '$lib/components/ProductForm.svelte';
|
||||||
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 { form }: { form: ActionData } = $props();
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
||||||
// ── enum options ──────────────────────────────────────────────────────────
|
|
||||||
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 tristateOptions = [
|
|
||||||
{ value: '', label: 'Unknown' },
|
|
||||||
{ value: 'true', label: 'Yes' },
|
|
||||||
{ value: 'false', label: 'No' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── controlled select state ───────────────────────────────────────────────
|
|
||||||
let category = $state('');
|
|
||||||
let recommendedTime = $state('');
|
|
||||||
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>
|
||||||
|
|
@ -62,338 +22,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" use:enhance class="space-y-6">
|
<form method="POST" use:enhance class="space-y-6">
|
||||||
|
<ProductForm />
|
||||||
<!-- ── 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" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="brand">Brand *</Label>
|
|
||||||
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" />
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="url">URL</Label>
|
|
||||||
<Input id="url" name="url" type="url" placeholder="https://…" />
|
|
||||||
</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">
|
|
||||||
<Label>Category *</Label>
|
|
||||||
<input type="hidden" name="category" value={category} />
|
|
||||||
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
|
||||||
<SelectTrigger>{category ? label(category) : 'Select category'}</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{#each categories as cat}
|
|
||||||
<SelectItem value={cat}>{label(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 ? 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">
|
|
||||||
<Label>Absorption speed</Label>
|
|
||||||
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
|
|
||||||
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
|
|
||||||
<SelectTrigger>{absorptionSpeed ? label(absorptionSpeed) : 'Select speed'}</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{#each absorptionSpeeds as s}
|
|
||||||
<SelectItem value={s}>{label(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 items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<input type="checkbox" name="recommended_for" value={st} class="rounded border-input" />
|
|
||||||
{label(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 items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<input type="checkbox" name="targets" value={sc} class="rounded border-input" />
|
|
||||||
{label(sc)}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</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 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="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 class="flex gap-6">
|
|
||||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<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 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" />
|
|
||||||
</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>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">
|
<div class="flex gap-3 pb-6">
|
||||||
<Button type="submit">Create product</Button>
|
<Button type="submit">Create product</Button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue