965 lines
40 KiB
Svelte
965 lines
40 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import type { Product } from '$lib/types';
|
|
import type { IngredientFunction } 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';
|
|
import { parseProductText, type ProductParseResponse } from '$lib/api';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
|
|
let { product }: { product?: Product } = $props();
|
|
|
|
// ── 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 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', 'anti_aging'
|
|
];
|
|
|
|
// ── Translated label maps ─────────────────────────────────────────────────
|
|
|
|
const categoryLabels = $derived<Record<string, string>>({
|
|
cleanser: m["productForm_categoryCleanser"](),
|
|
toner: m["productForm_categoryToner"](),
|
|
essence: m["productForm_categoryEssence"](),
|
|
serum: m["productForm_categorySerum"](),
|
|
moisturizer: m["productForm_categoryMoisturizer"](),
|
|
spf: m["productForm_categorySpf"](),
|
|
mask: m["productForm_categoryMask"](),
|
|
exfoliant: m["productForm_categoryExfoliant"](),
|
|
hair_treatment: m["productForm_categoryHairTreatment"](),
|
|
tool: m["productForm_categoryTool"](),
|
|
spot_treatment: m["productForm_categorySpotTreatment"](),
|
|
oil: m["productForm_categoryOil"]()
|
|
});
|
|
|
|
const textureLabels = $derived<Record<string, string>>({
|
|
watery: m["productForm_textureWatery"](),
|
|
gel: m["productForm_textureGel"](),
|
|
emulsion: m["productForm_textureEmulsion"](),
|
|
cream: m["productForm_textureCream"](),
|
|
oil: m["productForm_textureOil"](),
|
|
balm: m["productForm_textureBalm"](),
|
|
foam: m["productForm_textureFoam"](),
|
|
fluid: m["productForm_textureFluid"]()
|
|
});
|
|
|
|
const absorptionLabels = $derived<Record<string, string>>({
|
|
very_fast: m["productForm_absorptionVeryFast"](),
|
|
fast: m["productForm_absorptionFast"](),
|
|
moderate: m["productForm_absorptionModerate"](),
|
|
slow: m["productForm_absorptionSlow"](),
|
|
very_slow: m["productForm_absorptionVerySlow"]()
|
|
});
|
|
|
|
const skinTypeLabels = $derived<Record<string, string>>({
|
|
dry: m["productForm_skinTypeDry"](),
|
|
oily: m["productForm_skinTypeOily"](),
|
|
combination: m["productForm_skinTypeCombination"](),
|
|
sensitive: m["productForm_skinTypeSensitive"](),
|
|
normal: m["productForm_skinTypeNormal"](),
|
|
acne_prone: m["productForm_skinTypeAcneProne"]()
|
|
});
|
|
|
|
const skinConcernLabels = $derived<Record<string, string>>({
|
|
acne: m["productForm_concernAcne"](),
|
|
rosacea: m["productForm_concernRosacea"](),
|
|
hyperpigmentation: m["productForm_concernHyperpigmentation"](),
|
|
aging: m["productForm_concernAging"](),
|
|
dehydration: m["productForm_concernDehydration"](),
|
|
redness: m["productForm_concernRedness"](),
|
|
damaged_barrier: m["productForm_concernDamagedBarrier"](),
|
|
pore_visibility: m["productForm_concernPoreVisibility"](),
|
|
uneven_texture: m["productForm_concernUnevenTexture"](),
|
|
hair_growth: m["productForm_concernHairGrowth"](),
|
|
sebum_excess: m["productForm_concernSebumExcess"]()
|
|
});
|
|
|
|
const ingFunctionLabels = $derived<Record<string, string>>({
|
|
humectant: m["productForm_fnHumectant"](),
|
|
emollient: m["productForm_fnEmollient"](),
|
|
occlusive: m["productForm_fnOcclusive"](),
|
|
exfoliant_aha: m["productForm_fnExfoliantAha"](),
|
|
exfoliant_bha: m["productForm_fnExfoliantBha"](),
|
|
exfoliant_pha: m["productForm_fnExfoliantPha"](),
|
|
retinoid: m["productForm_fnRetinoid"](),
|
|
antioxidant: m["productForm_fnAntioxidant"](),
|
|
soothing: m["productForm_fnSoothing"](),
|
|
barrier_support: m["productForm_fnBarrierSupport"](),
|
|
brightening: m["productForm_fnBrightening"](),
|
|
anti_acne: m["productForm_fnAntiAcne"](),
|
|
ceramide: m["productForm_fnCeramide"](),
|
|
niacinamide: m["productForm_fnNiacinamide"](),
|
|
sunscreen: m["productForm_fnSunscreen"](),
|
|
peptide: m["productForm_fnPeptide"](),
|
|
hair_growth_stimulant: m["productForm_fnHairGrowth"](),
|
|
prebiotic: m["productForm_fnPrebiotic"](),
|
|
vitamin_c: m["productForm_fnVitaminC"](),
|
|
anti_aging: m["productForm_fnAntiAging"]()
|
|
});
|
|
|
|
const tristate = $derived([
|
|
{ value: '', label: m.common_unknown() },
|
|
{ value: 'true', label: m.common_yes() },
|
|
{ value: 'false', label: m.common_no() }
|
|
]);
|
|
|
|
function tristateLabel(val: string): string {
|
|
return val === '' ? m.common_unknown() : val === 'true' ? m.common_yes() : m.common_no();
|
|
}
|
|
|
|
const effectFields = $derived([
|
|
{ key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() },
|
|
{ key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() },
|
|
{ key: 'barrier_repair_strength' as const, label: m["productForm_effectBarrierRepair"]() },
|
|
{ key: 'soothing_strength' as const, label: m["productForm_effectSoothing"]() },
|
|
{ key: 'exfoliation_strength' as const, label: m["productForm_effectExfoliation"]() },
|
|
{ key: 'retinoid_strength' as const, label: m["productForm_effectRetinoid"]() },
|
|
{ key: 'irritation_risk' as const, label: m["productForm_effectIrritation"]() },
|
|
{ key: 'comedogenic_risk' as const, label: m["productForm_effectComedogenic"]() },
|
|
{ key: 'barrier_disruption_risk' as const, label: m["productForm_effectBarrierDisruption"]() },
|
|
{ key: 'dryness_risk' as const, label: m["productForm_effectDryness"]() },
|
|
{ key: 'brightening_strength' as const, label: m["productForm_effectBrightening"]() },
|
|
{ key: 'anti_acne_strength' as const, label: m["productForm_effectAntiAcne"]() },
|
|
{ key: 'anti_aging_strength' as const, label: m["productForm_effectAntiAging"]() }
|
|
]);
|
|
|
|
// ── Controlled text/number inputs ────────────────────────────────────────
|
|
|
|
let name = $state(untrack(() => product?.name ?? ''));
|
|
let brand = $state(untrack(() => product?.brand ?? ''));
|
|
let lineName = $state(untrack(() => product?.line_name ?? ''));
|
|
let url = $state(untrack(() => product?.url ?? ''));
|
|
let sku = $state(untrack(() => product?.sku ?? ''));
|
|
let barcode = $state(untrack(() => product?.barcode ?? ''));
|
|
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
|
|
let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : '')));
|
|
let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : '')));
|
|
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
|
|
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
|
|
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
|
|
let minIntervalHours = $state(untrack(() => (product?.min_interval_hours != null ? String(product.min_interval_hours) : '')));
|
|
let maxFrequencyPerWeek = $state(untrack(() => (product?.max_frequency_per_week != null ? String(product.max_frequency_per_week) : '')));
|
|
let needleLengthMm = $state(untrack(() => (product?.needle_length_mm != null ? String(product.needle_length_mm) : '')));
|
|
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
|
|
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
|
|
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
|
|
|
|
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
|
|
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
|
|
let isMedication = $state(untrack(() => product?.is_medication ?? false));
|
|
let isTool = $state(untrack(() => product?.is_tool ?? false));
|
|
|
|
// ── AI pre-fill state ─────────────────────────────────────────────────────
|
|
|
|
let aiPanelOpen = $state(false);
|
|
let aiText = $state('');
|
|
let aiLoading = $state(false);
|
|
let aiError = $state('');
|
|
|
|
async function parseWithAi() {
|
|
if (!aiText.trim()) return;
|
|
aiLoading = true;
|
|
aiError = '';
|
|
try {
|
|
const r = await parseProductText(aiText);
|
|
applyAiResult(r);
|
|
aiPanelOpen = false;
|
|
} catch (e) {
|
|
aiError = (e as Error).message;
|
|
} finally {
|
|
aiLoading = false;
|
|
}
|
|
}
|
|
|
|
function applyAiResult(r: ProductParseResponse) {
|
|
if (r.name) name = r.name;
|
|
if (r.brand) brand = r.brand;
|
|
if (r.line_name) lineName = r.line_name;
|
|
if (r.url) url = r.url;
|
|
if (r.sku) sku = r.sku;
|
|
if (r.barcode) barcode = r.barcode;
|
|
if (r.usage_notes) usageNotes = r.usage_notes;
|
|
if (r.category) category = r.category;
|
|
if (r.recommended_time) recommendedTime = r.recommended_time;
|
|
if (r.texture) texture = r.texture;
|
|
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
|
|
if (r.price_amount != null) priceAmount = String(r.price_amount);
|
|
if (r.price_currency) priceCurrency = r.price_currency;
|
|
if (r.leave_on != null) leaveOn = String(r.leave_on);
|
|
if (r.size_ml != null) sizeMl = String(r.size_ml);
|
|
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
|
|
if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g);
|
|
if (r.pao_months != null) paoMonths = String(r.pao_months);
|
|
if (r.ph_min != null) phMin = String(r.ph_min);
|
|
if (r.ph_max != null) phMax = String(r.ph_max);
|
|
if (r.min_interval_hours != null) minIntervalHours = String(r.min_interval_hours);
|
|
if (r.max_frequency_per_week != null) maxFrequencyPerWeek = String(r.max_frequency_per_week);
|
|
if (r.needle_length_mm != null) needleLengthMm = String(r.needle_length_mm);
|
|
if (r.fragrance_free != null) fragranceFree = String(r.fragrance_free);
|
|
if (r.essential_oils_free != null) essentialOilsFree = String(r.essential_oils_free);
|
|
if (r.alcohol_denat_free != null) alcoholDenatFree = String(r.alcohol_denat_free);
|
|
if (r.pregnancy_safe != null) pregnancySafe = String(r.pregnancy_safe);
|
|
if (r.recommended_for?.length) recommendedFor = [...r.recommended_for];
|
|
if (r.targets?.length) targetConcerns = [...r.targets];
|
|
if (r.is_medication != null) isMedication = r.is_medication;
|
|
if (r.is_tool != null) isTool = r.is_tool;
|
|
if (r.inci?.length) inciText = r.inci.join('\n');
|
|
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
|
|
if (r.actives?.length) {
|
|
actives = r.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) : ''
|
|
}));
|
|
}
|
|
if (r.product_effect_profile) {
|
|
effectValues = { ...effectValues, ...r.product_effect_profile };
|
|
}
|
|
if (r.context_rules) {
|
|
const cr = r.context_rules;
|
|
if (cr.safe_after_shaving != null) ctxAfterShaving = String(cr.safe_after_shaving);
|
|
if (cr.safe_after_acids != null) ctxAfterAcids = String(cr.safe_after_acids);
|
|
if (cr.safe_after_retinoids != null) ctxAfterRetinoids = String(cr.safe_after_retinoids);
|
|
if (cr.safe_with_compromised_barrier != null) ctxCompromisedBarrier = String(cr.safe_with_compromised_barrier);
|
|
if (cr.low_uv_only != null) ctxLowUvOnly = String(cr.low_uv_only);
|
|
}
|
|
}
|
|
|
|
// ── Select reactive state ─────────────────────────────────────────────────
|
|
|
|
let category = $state(untrack(() => product?.category ?? ''));
|
|
let recommendedTime = $state(untrack(() => product?.recommended_time ?? ''));
|
|
let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true')));
|
|
let texture = $state(untrack(() => product?.texture ?? ''));
|
|
let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? ''));
|
|
let priceAmount = $state(untrack(() => (product?.price_amount != null ? String(product.price_amount) : '')));
|
|
let priceCurrency = $state(untrack(() => product?.price_currency ?? 'PLN'));
|
|
let fragranceFree = $state(
|
|
untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : ''))
|
|
);
|
|
let essentialOilsFree = $state(
|
|
untrack(() => (product?.essential_oils_free != null ? String(product.essential_oils_free) : ''))
|
|
);
|
|
let alcoholDenatFree = $state(
|
|
untrack(() => (product?.alcohol_denat_free != null ? String(product.alcohol_denat_free) : ''))
|
|
);
|
|
let pregnancySafe = $state(
|
|
untrack(() => (product?.pregnancy_safe != null ? String(product.pregnancy_safe) : ''))
|
|
);
|
|
let personalRepurchaseIntent = $state(
|
|
untrack(() => (product?.personal_repurchase_intent != null ? String(product.personal_repurchase_intent) : ''))
|
|
);
|
|
|
|
// context rules tristate
|
|
const cr = untrack(() => 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 = untrack(() => 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(
|
|
untrack(() =>
|
|
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) }
|
|
: {})
|
|
}))
|
|
)
|
|
);
|
|
|
|
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>
|
|
|
|
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader>
|
|
<button type="button" class="flex w-full items-center justify-between text-left"
|
|
onclick={() => (aiPanelOpen = !aiPanelOpen)}>
|
|
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
|
|
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
|
</button>
|
|
</CardHeader>
|
|
{#if aiPanelOpen}
|
|
<CardContent class="space-y-3">
|
|
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
|
|
<textarea bind:value={aiText} rows="6"
|
|
placeholder={m["productForm_pasteText"]()}
|
|
class={textareaClass}></textarea>
|
|
{#if aiError}
|
|
<p class="text-sm text-destructive">{aiError}</p>
|
|
{/if}
|
|
<Button type="button" onclick={parseWithAi}
|
|
disabled={aiLoading || !aiText.trim()}>
|
|
{aiLoading ? m["productForm_parsing"]() : m["productForm_parseWithAI"]()}
|
|
</Button>
|
|
</CardContent>
|
|
{/if}
|
|
</Card>
|
|
|
|
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="name">{m["productForm_name"]()}</Label>
|
|
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="brand">{m["productForm_brand"]()}</Label>
|
|
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="line_name">{m["productForm_lineName"]()}</Label>
|
|
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="url">{m["productForm_url"]()}</Label>
|
|
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="sku">{m["productForm_sku"]()}</Label>
|
|
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="barcode">{m["productForm_barcode"]()}</Label>
|
|
<Input id="barcode" name="barcode" placeholder={m["productForm_barcodePlaceholder"]()} bind:value={barcode} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Classification ────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="col-span-2 space-y-2">
|
|
<Label>{m["productForm_category"]()}</Label>
|
|
<input type="hidden" name="category" value={category} />
|
|
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
|
<SelectTrigger>{category ? categoryLabels[category] : m["productForm_selectCategory"]()}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each categories as cat}
|
|
<SelectItem value={cat}>{categoryLabels[cat]}</SelectItem>
|
|
{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_time"]()}</Label>
|
|
<input type="hidden" name="recommended_time" value={recommendedTime} />
|
|
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
|
|
<SelectTrigger>
|
|
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="am">AM</SelectItem>
|
|
<SelectItem value="pm">PM</SelectItem>
|
|
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_leaveOn"]()}</Label>
|
|
<input type="hidden" name="leave_on" value={leaveOn} />
|
|
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
|
|
<SelectTrigger>{leaveOn === 'true' ? m["productForm_leaveOnYes"]() : m["productForm_leaveOnNo"]()}</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">{m["productForm_leaveOnYes"]()}</SelectItem>
|
|
<SelectItem value="false">{m["productForm_leaveOnNo"]()}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_texture"]()}</Label>
|
|
<input type="hidden" name="texture" value={texture} />
|
|
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
|
<SelectTrigger>{texture ? textureLabels[texture] : m["productForm_selectTexture"]()}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each textures as t}
|
|
<SelectItem value={t}>{textureLabels[t]}</SelectItem>
|
|
{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_absorptionSpeed"]()}</Label>
|
|
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
|
|
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
|
|
<SelectTrigger>{absorptionSpeed ? absorptionLabels[absorptionSpeed] : m["productForm_selectSpeed"]()}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each absorptionSpeeds as s}
|
|
<SelectItem value={s}>{absorptionLabels[s]}</SelectItem>
|
|
{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_skinProfile"]()}</CardTitle></CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_recommendedFor"]()}</Label>
|
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{#each skinTypes as st}
|
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
name="recommended_for"
|
|
value={st}
|
|
checked={recommendedFor.includes(st)}
|
|
onchange={() => {
|
|
if (recommendedFor.includes(st))
|
|
recommendedFor = recommendedFor.filter((s) => s !== st);
|
|
else
|
|
recommendedFor = [...recommendedFor, st];
|
|
}}
|
|
class="rounded border-input"
|
|
/>
|
|
{skinTypeLabels[st]}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_targetConcerns"]()}</Label>
|
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{#each skinConcerns as sc}
|
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
name="targets"
|
|
value={sc}
|
|
checked={targetConcerns.includes(sc)}
|
|
onchange={() => {
|
|
if (targetConcerns.includes(sc))
|
|
targetConcerns = targetConcerns.filter((s) => s !== sc);
|
|
else
|
|
targetConcerns = [...targetConcerns, sc];
|
|
}}
|
|
class="rounded border-input"
|
|
/>
|
|
{skinConcernLabels[sc]}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="contraindications">{m["productForm_contraindications"]()}</Label>
|
|
<textarea
|
|
id="contraindications"
|
|
name="contraindications"
|
|
rows="2"
|
|
placeholder={m["productForm_contraindicationsPlaceholder"]()}
|
|
class={textareaClass}
|
|
bind:value={contraindicationsText}
|
|
></textarea>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Ingredients ────────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_ingredients"]()}</CardTitle></CardHeader>
|
|
<CardContent class="space-y-6">
|
|
<div class="space-y-2">
|
|
<Label for="inci">{m["productForm_inciList"]()}</Label>
|
|
<textarea
|
|
id="inci"
|
|
name="inci"
|
|
rows="5"
|
|
placeholder="Aqua Glycerin Niacinamide"
|
|
class={textareaClass}
|
|
bind:value={inciText}
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<Label>{m["productForm_activeIngredients"]()}</Label>
|
|
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</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="flex items-end gap-2">
|
|
<div class="min-w-0 flex-1 space-y-1">
|
|
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
|
|
<Input
|
|
placeholder="e.g. Niacinamide"
|
|
bind:value={active.name}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onclick={() => removeActive(i)}
|
|
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
|
>✕</Button>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<div class="space-y-1">
|
|
<Label class="text-xs">{m["productForm_activePercent"]()}</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">{m["productForm_activeStrength"]()}</Label>
|
|
<select class={selectClass} bind:value={active.strength_level}>
|
|
<option value="">—</option>
|
|
<option value="1">{m["productForm_strengthLow"]()}</option>
|
|
<option value="2">{m["productForm_strengthMedium"]()}</option>
|
|
<option value="3">{m["productForm_strengthHigh"]()}</option>
|
|
</select>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<Label class="text-xs">{m["productForm_activeIrritation"]()}</Label>
|
|
<select class={selectClass} bind:value={active.irritation_potential}>
|
|
<option value="">—</option>
|
|
<option value="1">{m["productForm_strengthLow"]()}</option>
|
|
<option value="2">{m["productForm_strengthMedium"]()}</option>
|
|
<option value="3">{m["productForm_strengthHigh"]()}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
|
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
|
{#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"
|
|
/>
|
|
{ingFunctionLabels[fn]}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if actives.length === 0}
|
|
<p class="text-sm text-muted-foreground">{m["productForm_noActives"]()}</p>
|
|
{/if}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{#each effectFields as field}
|
|
{@const key = field.key as keyof typeof effectValues}
|
|
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
|
|
<span class="text-xs text-muted-foreground">{field.label}</span>
|
|
<input
|
|
type="range"
|
|
name="effect_{field.key}"
|
|
min="0"
|
|
max="5"
|
|
step="1"
|
|
bind:value={effectValues[key]}
|
|
class="accent-primary"
|
|
/>
|
|
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
|
|
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
|
|
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}>
|
|
<SelectTrigger>{tristateLabel(ctxAfterShaving)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_ctxAfterAcids"]()}</Label>
|
|
<input type="hidden" name="ctx_safe_after_acids" value={ctxAfterAcids} />
|
|
<Select type="single" value={ctxAfterAcids} onValueChange={(v) => (ctxAfterAcids = v)}>
|
|
<SelectTrigger>{tristateLabel(ctxAfterAcids)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_ctxAfterRetinoids"]()}</Label>
|
|
<input type="hidden" name="ctx_safe_after_retinoids" value={ctxAfterRetinoids} />
|
|
<Select
|
|
type="single"
|
|
value={ctxAfterRetinoids}
|
|
onValueChange={(v) => (ctxAfterRetinoids = v)}
|
|
>
|
|
<SelectTrigger>{tristateLabel(ctxAfterRetinoids)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_ctxCompromisedBarrier"]()}</Label>
|
|
<input type="hidden" name="ctx_safe_with_compromised_barrier" value={ctxCompromisedBarrier} />
|
|
<Select
|
|
type="single"
|
|
value={ctxCompromisedBarrier}
|
|
onValueChange={(v) => (ctxCompromisedBarrier = v)}
|
|
>
|
|
<SelectTrigger>{tristateLabel(ctxCompromisedBarrier)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_ctxLowUvOnly"]()}</Label>
|
|
<input type="hidden" name="ctx_low_uv_only" value={ctxLowUvOnly} />
|
|
<Select type="single" value={ctxLowUvOnly} onValueChange={(v) => (ctxLowUvOnly = v)}>
|
|
<SelectTrigger>{tristateLabel(ctxLowUvOnly)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Product details ────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
<div class="space-y-2">
|
|
<Label for="price_amount">Price</Label>
|
|
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder="e.g. 79.99" bind:value={priceAmount} />
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="price_currency">Currency</Label>
|
|
<Input id="price_currency" name="price_currency" maxlength={3} placeholder="PLN" bind:value={priceCurrency} />
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="size_ml">{m["productForm_sizeMl"]()}</Label>
|
|
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" bind:value={sizeMl} />
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
|
|
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 120" bind:value={fullWeightG} />
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
|
|
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 30" bind:value={emptyWeightG} />
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
|
|
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder="e.g. 12" bind:value={paoMonths} />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
|
|
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="ph_max">{m["productForm_phMax"]()}</Label>
|
|
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder="e.g. 4.5" bind:value={phMax} />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="usage_notes">{m["productForm_usageNotes"]()}</Label>
|
|
<textarea
|
|
id="usage_notes"
|
|
name="usage_notes"
|
|
rows="2"
|
|
placeholder={m["productForm_usageNotesPlaceholder"]()}
|
|
class={textareaClass}
|
|
bind:value={usageNotes}
|
|
></textarea>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_fragranceFree"]()}</Label>
|
|
<input type="hidden" name="fragrance_free" value={fragranceFree} />
|
|
<Select type="single" value={fragranceFree} onValueChange={(v) => (fragranceFree = v)}>
|
|
<SelectTrigger>{tristateLabel(fragranceFree)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_essentialOilsFree"]()}</Label>
|
|
<input type="hidden" name="essential_oils_free" value={essentialOilsFree} />
|
|
<Select
|
|
type="single"
|
|
value={essentialOilsFree}
|
|
onValueChange={(v) => (essentialOilsFree = v)}
|
|
>
|
|
<SelectTrigger>{tristateLabel(essentialOilsFree)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_alcoholDenatFree"]()}</Label>
|
|
<input type="hidden" name="alcohol_denat_free" value={alcoholDenatFree} />
|
|
<Select
|
|
type="single"
|
|
value={alcoholDenatFree}
|
|
onValueChange={(v) => (alcoholDenatFree = v)}
|
|
>
|
|
<SelectTrigger>{tristateLabel(alcoholDenatFree)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_pregnancySafe"]()}</Label>
|
|
<input type="hidden" name="pregnancy_safe" value={pregnancySafe} />
|
|
<Select type="single" value={pregnancySafe} onValueChange={(v) => (pregnancySafe = v)}>
|
|
<SelectTrigger>{tristateLabel(pregnancySafe)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
|
|
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="max_frequency_per_week">{m["productForm_maxFrequencyPerWeek"]()}</Label>
|
|
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder="e.g. 3" bind:value={maxFrequencyPerWeek} />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-6">
|
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
|
<input type="hidden" name="is_medication" value={String(isMedication)} />
|
|
<input
|
|
type="checkbox"
|
|
checked={isMedication}
|
|
onchange={() => (isMedication = !isMedication)}
|
|
class="rounded border-input"
|
|
/>
|
|
{m["productForm_isMedication"]()}
|
|
</label>
|
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
|
<input type="hidden" name="is_tool" value={String(isTool)} />
|
|
<input
|
|
type="checkbox"
|
|
checked={isTool}
|
|
onchange={() => (isTool = !isTool)}
|
|
class="rounded border-input"
|
|
/>
|
|
{m["productForm_isTool"]()}
|
|
</label>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label>
|
|
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" bind:value={needleLengthMm} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
|
|
<Card>
|
|
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="space-y-2">
|
|
<Label>{m["productForm_repurchaseIntent"]()}</Label>
|
|
<input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} />
|
|
<Select
|
|
type="single"
|
|
value={personalRepurchaseIntent}
|
|
onValueChange={(v) => (personalRepurchaseIntent = v)}
|
|
>
|
|
<SelectTrigger>{tristateLabel(personalRepurchaseIntent)}</SelectTrigger>
|
|
<SelectContent>
|
|
{#each tristate as opt}
|
|
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
|
{/each}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
|
|
<textarea
|
|
id="personal_tolerance_notes"
|
|
name="personal_tolerance_notes"
|
|
rows="2"
|
|
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
|
|
class={textareaClass}
|
|
>{product?.personal_tolerance_notes ?? ''}</textarea>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|