diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte new file mode 100644 index 0000000..87218a4 --- /dev/null +++ b/frontend/src/lib/components/ProductForm.svelte @@ -0,0 +1,901 @@ + + + + + Basic info + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + Classification + +
+ + + +
+ +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+
+
+ + + + Skin profile + +
+ +
+ {#each skinTypes as st} + + {/each} +
+
+ +
+ +
+ {#each skinConcerns as sc} + + {/each} +
+
+ +
+ + +
+
+
+ + + + Ingredients + +
+ + +
+ +
+
+ + +
+ + + + {#each actives as active, i} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ {#each ingFunctions as fn} + + {/each} +
+
+
+ {/each} + + {#if actives.length === 0} +

No actives added yet.

+ {/if} +
+
+
+ + + + Effect profile (0–5) + +
+ {#each effectFields as field} + {@const key = field.key as keyof typeof effectValues} +
+ {field.label} + + {effectValues[key]} +
+ {/each} +
+
+
+ + + + Interactions + +
+ + +
+ +
+
+ + +
+ + + + {#each incompatibleWith as row, i} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {/each} + + {#if incompatibleWith.length === 0} +

No incompatibilities added.

+ {/if} +
+
+
+ + + + Context rules + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ + + + Product details + +
+
+ + + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + + + Safety flags + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ + + + Usage constraints + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + + + Personal notes + +
+ + + +
+ +
+ + +
+
+
diff --git a/frontend/src/routes/products/[id]/+page.server.ts b/frontend/src/routes/products/[id]/+page.server.ts index 578f604..34a1cf7 100644 --- a/frontend/src/routes/products/[id]/+page.server.ts +++ b/frontend/src/routes/products/[id]/+page.server.ts @@ -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 { + 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 | 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 = {}; + 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 = { update: async ({ params, request }) => { const form = await request.formData(); - const body: Record = {}; - for (const [key, value] of form.entries()) { - if (value !== '') body[key] = value; + + const name = form.get('name') as string; + 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 = { + 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); return { success: true, product }; } catch (e) { diff --git a/frontend/src/routes/products/[id]/+page.svelte b/frontend/src/routes/products/[id]/+page.svelte index 646f0b9..9379a4b 100644 --- a/frontend/src/routes/products/[id]/+page.svelte +++ b/frontend/src/routes/products/[id]/+page.svelte @@ -3,10 +3,11 @@ import type { ActionData, PageData } from './$types'; import { Badge } from '$lib/components/ui/badge'; 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 { Label } from '$lib/components/ui/label'; import { Separator } from '$lib/components/ui/separator'; + import ProductForm from '$lib/components/ProductForm.svelte'; let { data, form }: { data: PageData; form: ActionData } = $props(); let { product } = $derived(data); @@ -30,68 +31,14 @@
Saved.
{/if} - - - Details - -
- Brand{product.brand} - Line{product.line_name ?? '—'} - Time{product.recommended_time} - Leave-on{product.leave_on ? 'Yes' : 'No'} - Texture{product.texture ?? '—'} - pH - - {#if product.ph_min != null && product.ph_max != null} - {product.ph_min}–{product.ph_max} - {:else} - — - {/if} - -
- {#if product.targets.length} -
- Targets: - {#each product.targets as t} - {t.replace(/_/g, ' ')} - {/each} -
- {/if} - {#if product.actives?.length} -
-

Actives:

-
    - {#each product.actives as a} -
  • {a.name}{a.percent != null ? ` ${a.percent}%` : ''}
  • - {/each} -
-
- {/if} - {#if product.usage_notes} -

{product.usage_notes}

- {/if} -
-
+ +
+ - - - Quick edit - - -
- - -
-
- -
- -
-
+
+ +
+ @@ -136,7 +83,7 @@ {#if product.inventory.length}
{#each product.inventory as pkg} -
+
{pkg.is_opened ? 'Open' : 'Sealed'} @@ -163,8 +110,14 @@
-
{ if (!confirm('Delete this product?')) e.preventDefault(); }}> + { + if (!confirm('Delete this product?')) e.preventDefault(); + }} + >
diff --git a/frontend/src/routes/products/new/+page.server.ts b/frontend/src/routes/products/new/+page.server.ts index 0a9f9f1..e61017e 100644 --- a/frontend/src/routes/products/new/+page.server.ts +++ b/frontend/src/routes/products/new/+page.server.ts @@ -29,6 +29,47 @@ function parseOptionalString(v: string | null): string | 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 { + 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 | 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 = {}; + 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 = { default: async ({ request }) => { const form = await request.formData(); @@ -43,12 +84,10 @@ export const actions: Actions = { } const leave_on = form.get('leave_on') === 'true'; - - // Lists from checkboxes 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); - // INCI: split on newlines and commas const inci_raw = form.get('inci') as string; const inci = inci_raw ? inci_raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean) @@ -62,32 +101,21 @@ export const actions: Actions = { leave_on, recommended_for, targets, - inci + contraindications, + inci, + product_effect_profile: parseEffectProfile(form) }; // Optional strings - const optStrings: Array<[string, string]> = [ - ['line_name', 'line_name'], - ['url', 'url'], - ['sku', 'sku'], - ['barcode', 'barcode'], - ['usage_notes', 'usage_notes'], - ['personal_tolerance_notes', 'personal_tolerance_notes'] - ]; - for (const [field, key] of optStrings) { + for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) { 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) - const optEnums: Array<[string, string]> = [ - ['texture', 'texture'], - ['absorption_speed', 'absorption_speed'], - ['price_tier', 'price_tier'], - ]; - for (const [field, key] of optEnums) { + // Optional enum selects + for (const field of ['texture', 'absorption_speed', 'price_tier']) { const v = form.get(field) as string | null; - if (v) payload[key] = v; + if (v) payload[field] = v; } // Optional numbers @@ -106,34 +134,49 @@ export const actions: Actions = { const min_interval_hours = parseOptionalInt(form.get('min_interval_hours') as string | null); if (min_interval_hours !== undefined) payload.min_interval_hours = min_interval_hours; - const max_frequency_per_week = parseOptionalInt(form.get('max_frequency_per_week') as string | null); + const max_frequency_per_week = parseOptionalInt( + form.get('max_frequency_per_week') as string | null + ); if (max_frequency_per_week !== undefined) payload.max_frequency_per_week = max_frequency_per_week; const needle_length_mm = parseOptionalFloat(form.get('needle_length_mm') as string | null); if (needle_length_mm !== undefined) payload.needle_length_mm = needle_length_mm; - // Booleans from checkboxes (unchecked = not sent = false) + // Booleans from checkboxes payload.is_medication = form.get('is_medication') === 'true'; payload.is_tool = form.get('is_tool') === 'true'; - // Nullable booleans (tristate) - const fragranceFree = parseTristate(form.get('fragrance_free') as string | null); - if (fragranceFree !== undefined) payload.fragrance_free = fragranceFree; + // Nullable booleans + for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) { + 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); - if (essentialOilsFree !== undefined) payload.essential_oils_free = essentialOilsFree; + // Actives (JSON array) + 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); - if (alcoholDenatFree !== undefined) payload.alcohol_denat_free = alcoholDenatFree; + // Incompatible with (JSON array) + 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); - if (pregnancySafe !== undefined) payload.pregnancy_safe = pregnancySafe; + // Synergizes with + const synergizes = parseTextList(form.get('synergizes_with') as string | null); + if (synergizes.length > 0) payload.synergizes_with = synergizes; - const personalRepurchaseIntent = parseTristate( - form.get('personal_repurchase_intent') as string | null - ); - if (personalRepurchaseIntent !== undefined) - payload.personal_repurchase_intent = personalRepurchaseIntent; + // Context rules + const contextRules = parseContextRules(form); + if (contextRules) payload.context_rules = contextRules; let product; try { diff --git a/frontend/src/routes/products/new/+page.svelte b/frontend/src/routes/products/new/+page.svelte index 9cc6208..6ae1e5a 100644 --- a/frontend/src/routes/products/new/+page.svelte +++ b/frontend/src/routes/products/new/+page.svelte @@ -2,49 +2,9 @@ import { enhance } from '$app/forms'; import type { ActionData } from './$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 ProductForm from '$lib/components/ProductForm.svelte'; 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, ' '); - } New Product — innercontext @@ -62,338 +22,7 @@ {/if}
- - - - Basic info - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
-
-
- - - - Classification - -
- - - -
- -
-
- - - -
- -
- - - -
- -
- - - -
-
- -
- - - -
-
-
- - - - Skin profile - -
- -
- {#each skinTypes as st} - - {/each} -
-
- -
- -
- {#each skinConcerns as sc} - - {/each} -
-
-
-
- - - - Product details - -
-
- - - -
- -
- - -
- -
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - - - Safety flags - -
-
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
-
-
-
- - - - Ingredients & notes - -
- - -
- -
- - -
-
-
- - - - Usage constraints - -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
-
-
- - - - Personal notes - -
-
- - - -
-
- -
- - -
-
-
+