feat: AI pre-fill for product form via Gemini API
Add POST /products/parse-text endpoint that accepts raw product text, calls Gemini (google-genai) with a structured extraction prompt, and returns a partial ProductParseResponse. Frontend gains a collapsible "AI pre-fill" card at the top of ProductForm that merges the LLM response into all form fields reactively. - Backend: ProductParseRequest/Response schemas, system prompt with enum constraints, temperature=0.0 for deterministic extraction, effect_profile always returned in full - Frontend: parseProductText() in api.ts; controlled $state bindings for all text/number/checkbox inputs; applyAiResult() merges response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c413e27768
commit
31e030eaac
5 changed files with 721 additions and 101 deletions
|
|
@ -1,9 +1,13 @@
|
|||
import { PUBLIC_API_BASE } from '$env/static/public';
|
||||
import type {
|
||||
ActiveIngredient,
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
Product,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInteraction,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineStep,
|
||||
|
|
@ -73,6 +77,27 @@ export const updateInventory = (id: string, body: Record<string, unknown>): Prom
|
|||
api.patch(`/inventory/${id}`, body);
|
||||
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
|
||||
|
||||
export interface ProductParseResponse {
|
||||
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
|
||||
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
|
||||
leave_on?: boolean; price_tier?: string;
|
||||
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
|
||||
inci?: string[]; actives?: ActiveIngredient[];
|
||||
recommended_for?: string[]; targets?: string[];
|
||||
contraindications?: string[]; usage_notes?: string;
|
||||
fragrance_free?: boolean; essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
|
||||
product_effect_profile?: ProductEffectProfile;
|
||||
ph_min?: number; ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number; max_frequency_per_week?: number;
|
||||
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
|
||||
}
|
||||
|
||||
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
|
||||
api.post('/products/parse-text', { text });
|
||||
|
||||
// ─── Routines ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineListParams {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
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';
|
||||
|
||||
let { product }: { product?: Product } = $props();
|
||||
|
||||
|
|
@ -58,6 +59,118 @@
|
|||
{ key: 'anti_aging_strength', label: 'Anti-aging' }
|
||||
] as const;
|
||||
|
||||
// ── 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 synergizesWithText = $state(untrack(() => product?.synergizes_with?.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_tier) priceTier = r.price_tier;
|
||||
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.synergizes_with?.length) synergizesWithText = r.synergizes_with.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.incompatible_with?.length) {
|
||||
incompatibleWith = r.incompatible_with.map((i) => ({
|
||||
target: i.target,
|
||||
scope: i.scope,
|
||||
reason: i.reason ?? ''
|
||||
}));
|
||||
}
|
||||
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 ?? ''));
|
||||
|
|
@ -210,6 +323,35 @@
|
|||
'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>AI pre-fill</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">
|
||||
Wklej opis produktu ze strony, listę składników lub inny tekst.
|
||||
AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.
|
||||
</p>
|
||||
<textarea bind:value={aiText} rows="6"
|
||||
placeholder="Wklej tutaj opis produktu, składniki INCI..."
|
||||
class={textareaClass}></textarea>
|
||||
{#if aiError}
|
||||
<p class="text-sm text-destructive">{aiError}</p>
|
||||
{/if}
|
||||
<Button type="button" onclick={parseWithAi}
|
||||
disabled={aiLoading || !aiText.trim()}>
|
||||
{aiLoading ? 'Przetwarzam…' : 'Uzupełnij pola (AI)'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Basic info</CardTitle></CardHeader>
|
||||
|
|
@ -217,31 +359,31 @@
|
|||
<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 ?? ''} />
|
||||
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" bind:value={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 ?? ''} />
|
||||
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" bind:value={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 ?? ''} />
|
||||
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" bind:value={lineName} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="url">URL</Label>
|
||||
<Input id="url" name="url" type="url" placeholder="https://…" value={product?.url ?? ''} />
|
||||
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={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 ?? ''} />
|
||||
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" bind:value={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 ?? ''} />
|
||||
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" bind:value={barcode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -334,7 +476,13 @@
|
|||
type="checkbox"
|
||||
name="recommended_for"
|
||||
value={st}
|
||||
checked={product?.recommended_for?.includes(st as never) ?? false}
|
||||
checked={recommendedFor.includes(st)}
|
||||
onchange={() => {
|
||||
if (recommendedFor.includes(st))
|
||||
recommendedFor = recommendedFor.filter((s) => s !== st);
|
||||
else
|
||||
recommendedFor = [...recommendedFor, st];
|
||||
}}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{lbl(st)}
|
||||
|
|
@ -352,7 +500,13 @@
|
|||
type="checkbox"
|
||||
name="targets"
|
||||
value={sc}
|
||||
checked={product?.targets?.includes(sc as never) ?? false}
|
||||
checked={targetConcerns.includes(sc)}
|
||||
onchange={() => {
|
||||
if (targetConcerns.includes(sc))
|
||||
targetConcerns = targetConcerns.filter((s) => s !== sc);
|
||||
else
|
||||
targetConcerns = [...targetConcerns, sc];
|
||||
}}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{lbl(sc)}
|
||||
|
|
@ -369,7 +523,8 @@
|
|||
rows="2"
|
||||
placeholder="e.g. active rosacea flares"
|
||||
class={textareaClass}
|
||||
>{product?.contraindications?.join('\n') ?? ''}</textarea>
|
||||
bind:value={contraindicationsText}
|
||||
></textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -386,7 +541,8 @@
|
|||
rows="5"
|
||||
placeholder="Aqua Glycerin Niacinamide"
|
||||
class={textareaClass}
|
||||
>{product?.inci?.join('\n') ?? ''}</textarea>
|
||||
bind:value={inciText}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
|
@ -508,7 +664,8 @@
|
|||
rows="3"
|
||||
placeholder="Ceramides Niacinamide Retinoids"
|
||||
class={textareaClass}
|
||||
>{product?.synergizes_with?.join('\n') ?? ''}</textarea>
|
||||
bind:value={synergizesWithText}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
|
@ -662,83 +819,33 @@
|
|||
|
||||
<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 ?? ''}
|
||||
/>
|
||||
<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">Full weight (g)</Label>
|
||||
<Input
|
||||
id="full_weight_g"
|
||||
name="full_weight_g"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g. 120"
|
||||
value={product?.full_weight_g ?? ''}
|
||||
/>
|
||||
<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">Empty weight (g)</Label>
|
||||
<Input
|
||||
id="empty_weight_g"
|
||||
name="empty_weight_g"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g. 30"
|
||||
value={product?.empty_weight_g ?? ''}
|
||||
/>
|
||||
<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">PAO (months)</Label>
|
||||
<Input
|
||||
id="pao_months"
|
||||
name="pao_months"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
placeholder="e.g. 12"
|
||||
value={product?.pao_months ?? ''}
|
||||
/>
|
||||
<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-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 ?? ''}
|
||||
/>
|
||||
<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">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 ?? ''}
|
||||
/>
|
||||
<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>
|
||||
|
||||
|
|
@ -750,7 +857,8 @@
|
|||
rows="2"
|
||||
placeholder="e.g. Apply to damp skin, avoid eye area"
|
||||
class={textareaClass}
|
||||
>{product?.usage_notes ?? ''}</textarea>
|
||||
bind:value={usageNotes}
|
||||
></textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -830,46 +938,31 @@
|
|||
<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 ?? ''}
|
||||
/>
|
||||
<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">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 ?? ''}
|
||||
/>
|
||||
<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"
|
||||
name="is_medication"
|
||||
value="true"
|
||||
checked={product?.is_medication ?? false}
|
||||
checked={isMedication}
|
||||
onchange={() => (isMedication = !isMedication)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
Is medication
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_tool" value={String(isTool)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_tool"
|
||||
value="true"
|
||||
checked={product?.is_tool ?? false}
|
||||
checked={isTool}
|
||||
onchange={() => (isTool = !isTool)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
Is tool (e.g. dermaroller)
|
||||
|
|
@ -878,15 +971,7 @@
|
|||
|
||||
<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 ?? ''}
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue