From d4fbc1faf50a314bf99ceb4c690334771e46d241 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Wed, 4 Mar 2026 18:13:49 +0100 Subject: [PATCH] feat(frontend): streamline AI workflows and localize remaining UI copy Move product and skin AI helpers into modal flows, simplify product edit/inventory navigation, and improve responsive actions so core forms are faster to use. Localize remaining frontend labels/placeholders and strip deprecated Rollup output options to remove deploy-time build warnings. --- frontend/messages/en.json | 40 +- frontend/messages/pl.json | 40 +- .../src/lib/components/ProductForm.svelte | 455 ++++++++++----- frontend/src/routes/+layout.svelte | 37 +- .../routes/health/lab-results/+page.svelte | 2 +- frontend/src/routes/products/+page.svelte | 133 ++++- .../src/routes/products/[id]/+page.svelte | 539 +++++++++--------- frontend/src/routes/products/new/+page.svelte | 3 +- .../src/routes/products/suggest/+page.svelte | 5 +- .../src/routes/routines/[id]/+page.svelte | 27 +- .../routines/grooming-schedule/+page.svelte | 3 +- frontend/src/routes/routines/new/+page.svelte | 7 +- .../src/routes/routines/suggest/+page.svelte | 19 +- frontend/src/routes/skin/+page.svelte | 132 +++-- frontend/vite.config.ts | 11 + 15 files changed, 921 insertions(+), 532 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f247272..442d6dc 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -22,6 +22,12 @@ "common_unknown_value": "Unknown", "common_optional_notes": "optional", "common_steps": "steps", + "common_am": "AM", + "common_pm": "PM", + "common_toggleMenu": "Toggle menu", + "common_dragToReorder": "drag to reorder", + "common_editStep": "edit step", + "common_pricePerUse": "PLN/use", "dashboard_title": "Dashboard", "dashboard_subtitle": "Your recent health & skincare overview", @@ -61,8 +67,9 @@ "products_colBrand": "Brand", "products_colTargets": "Targets", "products_colTime": "Time", + "products_colPricePerUse": "PLN/use", "products_newTitle": "New Product", - "products_backToList": "← Products", + "products_backToList": "Products", "products_createProduct": "Create product", "products_saveChanges": "Save changes", "products_deleteProduct": "Delete product", @@ -106,7 +113,7 @@ "routines_addNew": "+ New routine", "routines_noRoutines": "No routines found.", "routines_newTitle": "New Routine", - "routines_backToList": "← Routines", + "routines_backToList": "Routines", "routines_detailsTitle": "Routine details", "routines_date": "Date *", "routines_amOrPm": "AM or PM *", @@ -124,12 +131,15 @@ "routines_dosePlaceholder": "e.g. 2 pumps", "routines_region": "Region", "routines_regionPlaceholder": "e.g. face", + "routines_action": "Action", + "routines_selectAction": "Select action", + "routines_actionNotesPlaceholder": "Optional notes", "routines_addStepBtn": "Add step", "routines_unknownStep": "Unknown step", "routines_noSteps": "No steps yet.", "grooming_title": "Grooming Schedule", - "grooming_backToRoutines": "← Routines", + "grooming_backToRoutines": "Routines", "grooming_addEntry": "+ Add entry", "grooming_entryAdded": "Entry added.", "grooming_entryUpdated": "Entry updated.", @@ -152,7 +162,7 @@ "grooming_daySunday": "Sunday", "suggest_title": "AI Routine Suggestion", - "suggest_backToRoutines": "← Routines", + "suggest_backToRoutines": "Routines", "suggest_singleTab": "Single routine", "suggest_batchTab": "Batch / Vacation", "suggest_singleParams": "Parameters", @@ -165,6 +175,8 @@ "suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.", "suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)", "suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.", + "suggest_minimizeProductsLabel": "Minimize products", + "suggest_minimizeProductsHint": "Limit the number of different products", "suggest_generateBtn": "Generate suggestion", "suggest_generating": "Generating…", "suggest_proposalTitle": "Suggestion", @@ -258,6 +270,7 @@ "labResults_flagNone": "None", "labResults_date": "Date *", "labResults_loincCode": "LOINC code *", + "labResults_loincExample": "e.g. 718-7", "labResults_testName": "Test name", "labResults_testNamePlaceholder": "e.g. Hemoglobin", "labResults_lab": "Lab", @@ -336,7 +349,7 @@ "productForm_aiPrefill": "AI pre-fill", "productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.", "productForm_pasteText": "Paste product description, INCI ingredients here...", - "productForm_parseWithAI": "Fill fields (AI)", + "productForm_parseWithAI": "Fill", "productForm_parsing": "Processing…", "productForm_basicInfo": "Basic info", "productForm_name": "Name *", @@ -346,6 +359,7 @@ "productForm_lineName": "Line / series", "productForm_lineNamePlaceholder": "e.g. Hydro Boost", "productForm_url": "URL", + "productForm_urlPlaceholder": "https://...", "productForm_sku": "SKU", "productForm_skuPlaceholder": "e.g. NTR-HB-50", "productForm_barcode": "Barcode / EAN", @@ -370,11 +384,14 @@ "productForm_contraindicationsPlaceholder": "e.g. active rosacea flares", "productForm_ingredients": "Ingredients", "productForm_inciList": "INCI list (one ingredient per line)", + "productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide", "productForm_activeIngredients": "Active ingredients", "productForm_addActive": "+ Add active", "productForm_noActives": "No actives added yet.", "productForm_activeName": "Name", + "productForm_activeNamePlaceholder": "e.g. Niacinamide", "productForm_activePercent": "%", + "productForm_activePercentPlaceholder": "e.g. 5", "productForm_activeStrength": "Strength", "productForm_activeIrritation": "Irritation", "productForm_activeFunctions": "Functions", @@ -387,6 +404,19 @@ "productForm_ctxLowUvOnly": "Low UV only (evening/covered)", "productForm_productDetails": "Product details", "productForm_priceTier": "Price tier", + "productForm_price": "Price", + "productForm_currency": "Currency", + "productForm_priceAmountPlaceholder": "e.g. 79.99", + "productForm_priceCurrencyPlaceholder": "PLN", + "productForm_sizePlaceholder": "e.g. 50", + "productForm_fullWeightPlaceholder": "e.g. 120", + "productForm_emptyWeightPlaceholder": "e.g. 30", + "productForm_paoPlaceholder": "e.g. 12", + "productForm_phMinPlaceholder": "e.g. 3.5", + "productForm_phMaxPlaceholder": "e.g. 4.5", + "productForm_minIntervalPlaceholder": "e.g. 24", + "productForm_maxFrequencyPlaceholder": "e.g. 3", + "productForm_needleLengthPlaceholder": "e.g. 0.25", "productForm_selectTier": "Select tier", "productForm_sizeMl": "Size (ml)", "productForm_fullWeightG": "Full weight (g)", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 7a3e492..46edcc8 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -22,6 +22,12 @@ "common_unknown_value": "Nieznane", "common_optional_notes": "opcjonalnie", "common_steps": "kroków", + "common_am": "AM", + "common_pm": "PM", + "common_toggleMenu": "Przełącz menu", + "common_dragToReorder": "przeciągnij, aby zmienić kolejność", + "common_editStep": "edytuj krok", + "common_pricePerUse": "PLN/use", "dashboard_title": "Dashboard", "dashboard_subtitle": "Przegląd zdrowia i pielęgnacji", @@ -63,8 +69,9 @@ "products_colBrand": "Marka", "products_colTargets": "Cele", "products_colTime": "Pora", + "products_colPricePerUse": "PLN/use", "products_newTitle": "Nowy produkt", - "products_backToList": "← Produkty", + "products_backToList": "Produkty", "products_createProduct": "Utwórz produkt", "products_saveChanges": "Zapisz zmiany", "products_deleteProduct": "Usuń produkt", @@ -110,7 +117,7 @@ "routines_addNew": "+ Nowa rutyna", "routines_noRoutines": "Nie znaleziono rutyn.", "routines_newTitle": "Nowa rutyna", - "routines_backToList": "← Rutyny", + "routines_backToList": "Rutyny", "routines_detailsTitle": "Szczegóły rutyny", "routines_date": "Data *", "routines_amOrPm": "AM lub PM *", @@ -128,12 +135,15 @@ "routines_dosePlaceholder": "np. 2 pompki", "routines_region": "Okolica", "routines_regionPlaceholder": "np. twarz", + "routines_action": "Czynność", + "routines_selectAction": "Wybierz czynność", + "routines_actionNotesPlaceholder": "Opcjonalne notatki", "routines_addStepBtn": "Dodaj krok", "routines_unknownStep": "Nieznany krok", "routines_noSteps": "Brak kroków.", "grooming_title": "Harmonogram pielęgnacji", - "grooming_backToRoutines": "← Rutyny", + "grooming_backToRoutines": "Rutyny", "grooming_addEntry": "+ Dodaj wpis", "grooming_entryAdded": "Wpis dodany.", "grooming_entryUpdated": "Wpis zaktualizowany.", @@ -156,7 +166,7 @@ "grooming_daySunday": "Niedziela", "suggest_title": "Propozycja rutyny AI", - "suggest_backToRoutines": "← Rutyny", + "suggest_backToRoutines": "Rutyny", "suggest_singleTab": "Jedna rutyna", "suggest_batchTab": "Batch / Urlop", "suggest_singleParams": "Parametry", @@ -169,6 +179,8 @@ "suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.", "suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)", "suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.", + "suggest_minimizeProductsLabel": "Minimalizuj produkty", + "suggest_minimizeProductsHint": "Ogranicz liczbę różnych produktów", "suggest_generateBtn": "Generuj propozycję", "suggest_generating": "Generuję…", "suggest_proposalTitle": "Propozycja", @@ -270,6 +282,7 @@ "labResults_flagNone": "Brak", "labResults_date": "Data *", "labResults_loincCode": "Kod LOINC *", + "labResults_loincExample": "np. 718-7", "labResults_testName": "Nazwa badania", "labResults_testNamePlaceholder": "np. Hemoglobina", "labResults_lab": "Laboratorium", @@ -350,7 +363,7 @@ "productForm_aiPrefill": "Uzupełnienie AI", "productForm_aiPrefillText": "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.", "productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...", - "productForm_parseWithAI": "Uzupełnij pola (AI)", + "productForm_parseWithAI": "Uzupełnij", "productForm_parsing": "Przetwarzam…", "productForm_basicInfo": "Informacje podstawowe", "productForm_name": "Nazwa *", @@ -360,6 +373,7 @@ "productForm_lineName": "Linia / seria", "productForm_lineNamePlaceholder": "np. Hydro Boost", "productForm_url": "URL", + "productForm_urlPlaceholder": "https://...", "productForm_sku": "SKU", "productForm_skuPlaceholder": "np. NTR-HB-50", "productForm_barcode": "Kod kreskowy / EAN", @@ -384,11 +398,14 @@ "productForm_contraindicationsPlaceholder": "np. aktywna rosacea", "productForm_ingredients": "Składniki", "productForm_inciList": "Lista INCI (jeden składnik na linię)", + "productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide", "productForm_activeIngredients": "Składniki aktywne", "productForm_addActive": "+ Dodaj aktywny", "productForm_noActives": "Brak składników aktywnych.", "productForm_activeName": "Nazwa", + "productForm_activeNamePlaceholder": "np. Niacinamide", "productForm_activePercent": "%", + "productForm_activePercentPlaceholder": "np. 5", "productForm_activeStrength": "Siła", "productForm_activeIrritation": "Podrażnienie", "productForm_activeFunctions": "Funkcje", @@ -401,6 +418,19 @@ "productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)", "productForm_productDetails": "Szczegóły produktu", "productForm_priceTier": "Przedział cenowy", + "productForm_price": "Cena", + "productForm_currency": "Waluta", + "productForm_priceAmountPlaceholder": "np. 79.99", + "productForm_priceCurrencyPlaceholder": "PLN", + "productForm_sizePlaceholder": "np. 50", + "productForm_fullWeightPlaceholder": "np. 120", + "productForm_emptyWeightPlaceholder": "np. 30", + "productForm_paoPlaceholder": "np. 12", + "productForm_phMinPlaceholder": "np. 3.5", + "productForm_phMaxPlaceholder": "np. 4.5", + "productForm_minIntervalPlaceholder": "np. 24", + "productForm_maxFrequencyPlaceholder": "np. 3", + "productForm_needleLengthPlaceholder": "np. 0.25", "productForm_selectTier": "Wybierz przedział", "productForm_sizeMl": "Rozmiar (ml)", "productForm_fullWeightG": "Waga pełna (g)", diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte index 004dfe7..733cfdc 100644 --- a/frontend/src/lib/components/ProductForm.svelte +++ b/frontend/src/lib/components/ProductForm.svelte @@ -6,11 +6,29 @@ 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 { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { parseProductText, type ProductParseResponse } from '$lib/api'; import { m } from '$lib/paraglide/messages.js'; + import { Sparkles, X } from 'lucide-svelte'; - let { product }: { product?: Product } = $props(); + let { + product, + dirty = $bindable(false), + saveVersion = 0, + showAiTrigger = true, + computedPriceLabel, + computedPricePerUseLabel, + computedPriceTierLabel + }: { + product?: Product; + dirty?: boolean; + saveVersion?: number; + showAiTrigger?: boolean; + computedPriceLabel?: string; + computedPricePerUseLabel?: string; + computedPriceTierLabel?: string; + } = $props(); // ── Enum option lists ───────────────────────────────────────────────────── @@ -162,6 +180,7 @@ let usageNotes = $state(untrack(() => product?.usage_notes ?? '')); let inciText = $state(untrack(() => product?.inci?.join('\n') ?? '')); let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? '')); + let personalToleranceNotes = $state(untrack(() => product?.personal_tolerance_notes ?? '')); let recommendedFor = $state(untrack(() => [...(product?.recommended_for ?? [])])); let targetConcerns = $state(untrack(() => [...(product?.targets ?? [])])); @@ -170,10 +189,12 @@ // ── AI pre-fill state ───────────────────────────────────────────────────── - let aiPanelOpen = $state(false); + let aiModalOpen = $state(false); let aiText = $state(''); let aiLoading = $state(false); let aiError = $state(''); + let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic'); + let activesPanelOpen = $state(true); async function parseWithAi() { if (!aiText.trim()) return; @@ -182,7 +203,7 @@ try { const r = await parseProductText(aiText); applyAiResult(r); - aiPanelOpen = false; + aiModalOpen = false; } catch (e) { aiError = (e as Error).message; } finally { @@ -363,36 +384,157 @@ 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'; + + export function openAiModal() { + aiError = ''; + aiModalOpen = true; + } + + function closeAiModal() { + if (aiLoading) return; + aiModalOpen = false; + } + + function handleModalKeydown(event: KeyboardEvent) { + if (!aiModalOpen) return; + if (event.key === 'Escape') { + event.preventDefault(); + closeAiModal(); + } + } + + const formFingerprint = $derived( + JSON.stringify({ + name, + brand, + lineName, + url, + sku, + barcode, + sizeMl, + fullWeightG, + emptyWeightG, + paoMonths, + phMin, + phMax, + minIntervalHours, + maxFrequencyPerWeek, + needleLengthMm, + usageNotes, + inciText, + contraindicationsText, + personalToleranceNotes, + recommendedFor, + targetConcerns, + isMedication, + isTool, + category, + recommendedTime, + leaveOn, + texture, + absorptionSpeed, + priceAmount, + priceCurrency, + fragranceFree, + essentialOilsFree, + alcoholDenatFree, + pregnancySafe, + personalRepurchaseIntent, + ctxAfterShaving, + ctxAfterAcids, + ctxAfterRetinoids, + ctxCompromisedBarrier, + ctxLowUvOnly, + effectValues, + actives: activesJson + }) + ); + + let baselineFingerprint = $state(''); + let baselineProductId = $state(''); + let baselineSaveVersion = $state(-1); + + $effect(() => { + const currentProductId = product?.id ?? ''; + const currentFingerprint = formFingerprint; + + if ( + !baselineFingerprint || + currentProductId !== baselineProductId || + saveVersion !== baselineSaveVersion + ) { + baselineFingerprint = currentFingerprint; + baselineProductId = currentProductId; + baselineSaveVersion = saveVersion; + dirty = false; + return; + } + + dirty = currentFingerprint !== baselineFingerprint; + }); - - - - - - {#if aiPanelOpen} - -

{m["productForm_aiPrefillText"]()}

- - {#if aiError} -

{aiError}

- {/if} - -
- {/if} -
+ + + + + {m["productForm_basicInfo"]()} + {m["productForm_ingredients"]()} + {m["productForm_effectProfile"]()} + {m["productForm_productDetails"]()} + {m["productForm_personalNotes"]()} + + + +{#if showAiTrigger} +
+ +
+{/if} + +{#if aiModalOpen} + +
+ + +
+ {m["productForm_aiPrefill"]()} + +
+
+ +

{m["productForm_aiPrefillText"]()}

+ + {#if aiError} +

{aiError}

+ {/if} +
+ + +
+
+
+
+{/if} - + {m["productForm_basicInfo"]()}
@@ -412,7 +554,7 @@
- +
@@ -429,7 +571,7 @@ - + {m["productForm_classification"]()}
@@ -454,8 +596,8 @@ {recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()} - AM - PM + {m.common_am()} + {m.common_pm()} {m["productForm_timeBoth"]()} @@ -503,7 +645,7 @@ - + {m["productForm_skinProfile"]()}
@@ -569,7 +711,7 @@ - + {m["productForm_ingredients"]()}
@@ -578,7 +720,7 @@ id="inci" name="inci" rows="5" - placeholder="Aqua Glycerin Niacinamide" + placeholder={m["productForm_inciPlaceholder"]()} class={textareaClass} bind:value={inciText} > @@ -586,90 +728,99 @@
- +
- {#each actives as active, i} -
-
-
- - -
- -
-
-
- - + >
-
- - +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
-
- -
- {#each ingFunctions as fn} - - {/each} +
+ +
+ {#each ingFunctions as fn} + + {/each} +
-
- {/each} + {/each} - {#if actives.length === 0} -

{m["productForm_noActives"]()}

+ {#if actives.length === 0} +

{m["productForm_noActives"]()}

+ {/if} {/if}
- + {m["productForm_effectProfile"]()}
@@ -694,7 +845,7 @@ - + {m["productForm_contextRules"]()}
@@ -765,49 +916,70 @@ - + {m["productForm_productDetails"]()} -
-
- - +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
+ {#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel} +
+
+
+

{m["productForm_price"]()}

+

{computedPriceLabel ?? '-'}

+
+
+

{m.common_pricePerUse()}

+

{computedPricePerUseLabel ?? '-'}

+
+
+

{m["productForm_priceTier"]()}

+

{computedPriceTierLabel ?? 'n/a'}

+
+
+
+ {/if}
- +
- +
@@ -826,7 +998,7 @@ - + {m["productForm_safetyFlags"]()}
@@ -886,17 +1058,17 @@ - + {m["productForm_usageConstraints"]()}
- +
- +
@@ -925,13 +1097,13 @@
- +
- + {m["productForm_personalNotes"]()}
@@ -959,7 +1131,8 @@ rows="2" placeholder={m["productForm_toleranceNotesPlaceholder"]()} class={textareaClass} - >{product?.personal_tolerance_notes ?? ''} + bind:value={personalToleranceNotes} + >
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 8ce95c5..e47cb94 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,19 +4,30 @@ import { resolve } from '$app/paths'; import { m } from '$lib/paraglide/messages.js'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; + import { + House, + Package, + ClipboardList, + Scissors, + Pill, + FlaskConical, + Sparkles, + Menu, + X + } from 'lucide-svelte'; let { children } = $props(); let mobileMenuOpen = $state(false); const navItems = $derived([ - { href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' }, - { href: resolve('/products'), label: m.nav_products(), icon: '🧴' }, - { href: resolve('/routines'), label: m.nav_routines(), icon: '📋' }, - { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' }, - { href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' }, - { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' }, - { href: resolve('/skin'), label: m.nav_skin(), icon: '✨' } + { href: resolve('/'), label: m.nav_dashboard(), icon: House }, + { href: resolve('/products'), label: m.nav_products(), icon: Package }, + { href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList }, + { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors }, + { href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill }, + { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }, + { href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles } ]); function isActive(href: string) { @@ -41,12 +52,12 @@ type="button" onclick={() => (mobileMenuOpen = !mobileMenuOpen)} class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground" - aria-label="Toggle menu" + aria-label={m.common_toggleMenu()} > {#if mobileMenuOpen} - + {:else} - + {/if} @@ -60,7 +71,7 @@ onclick={() => (mobileMenuOpen = false)} aria-label={m.common_cancel()} > - +
- +
diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte index 36e98ba..d584c1f 100644 --- a/frontend/src/routes/products/+page.svelte +++ b/frontend/src/routes/products/+page.svelte @@ -6,6 +6,8 @@ import { m } from '$lib/paraglide/messages.js'; import { Badge } from '$lib/components/ui/badge'; import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + import { Sparkles, ArrowUp, ArrowDown } from 'lucide-svelte'; import { Table, TableBody, @@ -18,7 +20,11 @@ let { data }: { data: PageData } = $props(); type OwnershipFilter = 'all' | 'owned' | 'unowned'; + type SortKey = 'brand' | 'name' | 'time' | 'price'; let ownershipFilter = $state('all'); + let sortKey = $state('brand'); + let sortDirection = $state<'asc' | 'desc'>('asc'); + let searchQuery = $state(''); const CATEGORY_ORDER = [ 'cleanser', 'toner', 'essence', 'serum', 'moisturizer', @@ -29,14 +35,59 @@ return p.inventory?.some(inv => !inv.finished_at) ?? false; } + function setSort(nextKey: SortKey): void { + if (sortKey === nextKey) { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + return; + } + sortKey = nextKey; + sortDirection = 'asc'; + } + + function compareText(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: 'base' }); + } + + function comparePrice(a?: number, b?: number): number { + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + return a - b; + } + + function sortState(key: SortKey): '' | 'asc' | 'desc' { + if (sortKey !== key) return ''; + return sortDirection; + } + const groupedProducts = $derived((() => { let items = data.products; if (ownershipFilter === 'owned') items = items.filter(isOwned); if (ownershipFilter === 'unowned') items = items.filter(p => !isOwned(p)); + const q = searchQuery.trim().toLocaleLowerCase(); + if (q) { + items = items.filter((p) => { + const inName = p.name.toLocaleLowerCase().includes(q); + const inBrand = p.brand.toLocaleLowerCase().includes(q); + const inTargets = p.targets.some((t) => t.replace(/_/g, ' ').toLocaleLowerCase().includes(q)); + return inName || inBrand || inTargets; + }); + } + items = [...items].sort((a, b) => { - const bc = a.brand.localeCompare(b.brand); - return bc !== 0 ? bc : a.name.localeCompare(b.name); + let cmp = 0; + if (sortKey === 'brand') cmp = compareText(a.brand, b.brand); + if (sortKey === 'name') cmp = compareText(a.name, b.name); + if (sortKey === 'time') cmp = compareText(a.recommended_time, b.recommended_time); + if (sortKey === 'price') cmp = comparePrice(getPricePerUse(a), getPricePerUse(b)); + + if (cmp === 0) { + const byBrand = compareText(a.brand, b.brand); + cmp = byBrand !== 0 ? byBrand : compareText(a.name, b.name); + } + + return sortDirection === 'asc' ? cmp : -cmp; }); const map = new SvelteMap(); @@ -58,11 +109,15 @@ function formatPricePerUse(value?: number): string { if (value == null) return '-'; - return `${value.toFixed(2)} PLN/use`; + return `${value.toFixed(2)} ${m.common_pricePerUse()}`; } function formatTier(value?: string): string { if (!value) return 'n/a'; + if (value === 'budget') return m['productForm_priceBudget'](); + if (value === 'mid') return m['productForm_priceMid'](); + if (value === 'premium') return m['productForm_pricePremium'](); + if (value === 'luxury') return m['productForm_priceLuxury'](); return value; } @@ -70,16 +125,6 @@ return (product as Product & { price_per_use_pln?: number }).price_per_use_pln; } - function getTierSource(product: Product): string | undefined { - return (product as Product & { price_tier_source?: string }).price_tier_source; - } - - function sourceLabel(product: Product): string { - const source = getTierSource(product); - if (source === 'fallback') return 'fallback'; - if (source === 'insufficient_data') return 'insufficient'; - return 'category'; - } {m.products_title()} — innercontext @@ -91,7 +136,7 @@

{m.products_count({ count: totalCount })}

- +
@@ -108,16 +153,44 @@ {/each}
+
+
+ +
+
+ + + + +
+
+