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.
This commit is contained in:
parent
83ba4cc5c0
commit
d4fbc1faf5
15 changed files with 921 additions and 532 deletions
|
|
@ -22,6 +22,12 @@
|
||||||
"common_unknown_value": "Unknown",
|
"common_unknown_value": "Unknown",
|
||||||
"common_optional_notes": "optional",
|
"common_optional_notes": "optional",
|
||||||
"common_steps": "steps",
|
"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_title": "Dashboard",
|
||||||
"dashboard_subtitle": "Your recent health & skincare overview",
|
"dashboard_subtitle": "Your recent health & skincare overview",
|
||||||
|
|
@ -61,8 +67,9 @@
|
||||||
"products_colBrand": "Brand",
|
"products_colBrand": "Brand",
|
||||||
"products_colTargets": "Targets",
|
"products_colTargets": "Targets",
|
||||||
"products_colTime": "Time",
|
"products_colTime": "Time",
|
||||||
|
"products_colPricePerUse": "PLN/use",
|
||||||
"products_newTitle": "New Product",
|
"products_newTitle": "New Product",
|
||||||
"products_backToList": "← Products",
|
"products_backToList": "Products",
|
||||||
"products_createProduct": "Create product",
|
"products_createProduct": "Create product",
|
||||||
"products_saveChanges": "Save changes",
|
"products_saveChanges": "Save changes",
|
||||||
"products_deleteProduct": "Delete product",
|
"products_deleteProduct": "Delete product",
|
||||||
|
|
@ -106,7 +113,7 @@
|
||||||
"routines_addNew": "+ New routine",
|
"routines_addNew": "+ New routine",
|
||||||
"routines_noRoutines": "No routines found.",
|
"routines_noRoutines": "No routines found.",
|
||||||
"routines_newTitle": "New Routine",
|
"routines_newTitle": "New Routine",
|
||||||
"routines_backToList": "← Routines",
|
"routines_backToList": "Routines",
|
||||||
"routines_detailsTitle": "Routine details",
|
"routines_detailsTitle": "Routine details",
|
||||||
"routines_date": "Date *",
|
"routines_date": "Date *",
|
||||||
"routines_amOrPm": "AM or PM *",
|
"routines_amOrPm": "AM or PM *",
|
||||||
|
|
@ -124,12 +131,15 @@
|
||||||
"routines_dosePlaceholder": "e.g. 2 pumps",
|
"routines_dosePlaceholder": "e.g. 2 pumps",
|
||||||
"routines_region": "Region",
|
"routines_region": "Region",
|
||||||
"routines_regionPlaceholder": "e.g. face",
|
"routines_regionPlaceholder": "e.g. face",
|
||||||
|
"routines_action": "Action",
|
||||||
|
"routines_selectAction": "Select action",
|
||||||
|
"routines_actionNotesPlaceholder": "Optional notes",
|
||||||
"routines_addStepBtn": "Add step",
|
"routines_addStepBtn": "Add step",
|
||||||
"routines_unknownStep": "Unknown step",
|
"routines_unknownStep": "Unknown step",
|
||||||
"routines_noSteps": "No steps yet.",
|
"routines_noSteps": "No steps yet.",
|
||||||
|
|
||||||
"grooming_title": "Grooming Schedule",
|
"grooming_title": "Grooming Schedule",
|
||||||
"grooming_backToRoutines": "← Routines",
|
"grooming_backToRoutines": "Routines",
|
||||||
"grooming_addEntry": "+ Add entry",
|
"grooming_addEntry": "+ Add entry",
|
||||||
"grooming_entryAdded": "Entry added.",
|
"grooming_entryAdded": "Entry added.",
|
||||||
"grooming_entryUpdated": "Entry updated.",
|
"grooming_entryUpdated": "Entry updated.",
|
||||||
|
|
@ -152,7 +162,7 @@
|
||||||
"grooming_daySunday": "Sunday",
|
"grooming_daySunday": "Sunday",
|
||||||
|
|
||||||
"suggest_title": "AI Routine Suggestion",
|
"suggest_title": "AI Routine Suggestion",
|
||||||
"suggest_backToRoutines": "← Routines",
|
"suggest_backToRoutines": "Routines",
|
||||||
"suggest_singleTab": "Single routine",
|
"suggest_singleTab": "Single routine",
|
||||||
"suggest_batchTab": "Batch / Vacation",
|
"suggest_batchTab": "Batch / Vacation",
|
||||||
"suggest_singleParams": "Parameters",
|
"suggest_singleParams": "Parameters",
|
||||||
|
|
@ -165,6 +175,8 @@
|
||||||
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
|
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
|
||||||
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
|
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
|
||||||
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
|
"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_generateBtn": "Generate suggestion",
|
||||||
"suggest_generating": "Generating…",
|
"suggest_generating": "Generating…",
|
||||||
"suggest_proposalTitle": "Suggestion",
|
"suggest_proposalTitle": "Suggestion",
|
||||||
|
|
@ -258,6 +270,7 @@
|
||||||
"labResults_flagNone": "None",
|
"labResults_flagNone": "None",
|
||||||
"labResults_date": "Date *",
|
"labResults_date": "Date *",
|
||||||
"labResults_loincCode": "LOINC code *",
|
"labResults_loincCode": "LOINC code *",
|
||||||
|
"labResults_loincExample": "e.g. 718-7",
|
||||||
"labResults_testName": "Test name",
|
"labResults_testName": "Test name",
|
||||||
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
|
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
|
||||||
"labResults_lab": "Lab",
|
"labResults_lab": "Lab",
|
||||||
|
|
@ -336,7 +349,7 @@
|
||||||
"productForm_aiPrefill": "AI pre-fill",
|
"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_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_pasteText": "Paste product description, INCI ingredients here...",
|
||||||
"productForm_parseWithAI": "Fill fields (AI)",
|
"productForm_parseWithAI": "Fill",
|
||||||
"productForm_parsing": "Processing…",
|
"productForm_parsing": "Processing…",
|
||||||
"productForm_basicInfo": "Basic info",
|
"productForm_basicInfo": "Basic info",
|
||||||
"productForm_name": "Name *",
|
"productForm_name": "Name *",
|
||||||
|
|
@ -346,6 +359,7 @@
|
||||||
"productForm_lineName": "Line / series",
|
"productForm_lineName": "Line / series",
|
||||||
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
|
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
|
||||||
"productForm_url": "URL",
|
"productForm_url": "URL",
|
||||||
|
"productForm_urlPlaceholder": "https://...",
|
||||||
"productForm_sku": "SKU",
|
"productForm_sku": "SKU",
|
||||||
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
|
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
|
||||||
"productForm_barcode": "Barcode / EAN",
|
"productForm_barcode": "Barcode / EAN",
|
||||||
|
|
@ -370,11 +384,14 @@
|
||||||
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
|
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
|
||||||
"productForm_ingredients": "Ingredients",
|
"productForm_ingredients": "Ingredients",
|
||||||
"productForm_inciList": "INCI list (one ingredient per line)",
|
"productForm_inciList": "INCI list (one ingredient per line)",
|
||||||
|
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
|
||||||
"productForm_activeIngredients": "Active ingredients",
|
"productForm_activeIngredients": "Active ingredients",
|
||||||
"productForm_addActive": "+ Add active",
|
"productForm_addActive": "+ Add active",
|
||||||
"productForm_noActives": "No actives added yet.",
|
"productForm_noActives": "No actives added yet.",
|
||||||
"productForm_activeName": "Name",
|
"productForm_activeName": "Name",
|
||||||
|
"productForm_activeNamePlaceholder": "e.g. Niacinamide",
|
||||||
"productForm_activePercent": "%",
|
"productForm_activePercent": "%",
|
||||||
|
"productForm_activePercentPlaceholder": "e.g. 5",
|
||||||
"productForm_activeStrength": "Strength",
|
"productForm_activeStrength": "Strength",
|
||||||
"productForm_activeIrritation": "Irritation",
|
"productForm_activeIrritation": "Irritation",
|
||||||
"productForm_activeFunctions": "Functions",
|
"productForm_activeFunctions": "Functions",
|
||||||
|
|
@ -387,6 +404,19 @@
|
||||||
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
|
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
|
||||||
"productForm_productDetails": "Product details",
|
"productForm_productDetails": "Product details",
|
||||||
"productForm_priceTier": "Price tier",
|
"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_selectTier": "Select tier",
|
||||||
"productForm_sizeMl": "Size (ml)",
|
"productForm_sizeMl": "Size (ml)",
|
||||||
"productForm_fullWeightG": "Full weight (g)",
|
"productForm_fullWeightG": "Full weight (g)",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@
|
||||||
"common_unknown_value": "Nieznane",
|
"common_unknown_value": "Nieznane",
|
||||||
"common_optional_notes": "opcjonalnie",
|
"common_optional_notes": "opcjonalnie",
|
||||||
"common_steps": "kroków",
|
"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_title": "Dashboard",
|
||||||
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
|
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
|
||||||
|
|
@ -63,8 +69,9 @@
|
||||||
"products_colBrand": "Marka",
|
"products_colBrand": "Marka",
|
||||||
"products_colTargets": "Cele",
|
"products_colTargets": "Cele",
|
||||||
"products_colTime": "Pora",
|
"products_colTime": "Pora",
|
||||||
|
"products_colPricePerUse": "PLN/use",
|
||||||
"products_newTitle": "Nowy produkt",
|
"products_newTitle": "Nowy produkt",
|
||||||
"products_backToList": "← Produkty",
|
"products_backToList": "Produkty",
|
||||||
"products_createProduct": "Utwórz produkt",
|
"products_createProduct": "Utwórz produkt",
|
||||||
"products_saveChanges": "Zapisz zmiany",
|
"products_saveChanges": "Zapisz zmiany",
|
||||||
"products_deleteProduct": "Usuń produkt",
|
"products_deleteProduct": "Usuń produkt",
|
||||||
|
|
@ -110,7 +117,7 @@
|
||||||
"routines_addNew": "+ Nowa rutyna",
|
"routines_addNew": "+ Nowa rutyna",
|
||||||
"routines_noRoutines": "Nie znaleziono rutyn.",
|
"routines_noRoutines": "Nie znaleziono rutyn.",
|
||||||
"routines_newTitle": "Nowa rutyna",
|
"routines_newTitle": "Nowa rutyna",
|
||||||
"routines_backToList": "← Rutyny",
|
"routines_backToList": "Rutyny",
|
||||||
"routines_detailsTitle": "Szczegóły rutyny",
|
"routines_detailsTitle": "Szczegóły rutyny",
|
||||||
"routines_date": "Data *",
|
"routines_date": "Data *",
|
||||||
"routines_amOrPm": "AM lub PM *",
|
"routines_amOrPm": "AM lub PM *",
|
||||||
|
|
@ -128,12 +135,15 @@
|
||||||
"routines_dosePlaceholder": "np. 2 pompki",
|
"routines_dosePlaceholder": "np. 2 pompki",
|
||||||
"routines_region": "Okolica",
|
"routines_region": "Okolica",
|
||||||
"routines_regionPlaceholder": "np. twarz",
|
"routines_regionPlaceholder": "np. twarz",
|
||||||
|
"routines_action": "Czynność",
|
||||||
|
"routines_selectAction": "Wybierz czynność",
|
||||||
|
"routines_actionNotesPlaceholder": "Opcjonalne notatki",
|
||||||
"routines_addStepBtn": "Dodaj krok",
|
"routines_addStepBtn": "Dodaj krok",
|
||||||
"routines_unknownStep": "Nieznany krok",
|
"routines_unknownStep": "Nieznany krok",
|
||||||
"routines_noSteps": "Brak kroków.",
|
"routines_noSteps": "Brak kroków.",
|
||||||
|
|
||||||
"grooming_title": "Harmonogram pielęgnacji",
|
"grooming_title": "Harmonogram pielęgnacji",
|
||||||
"grooming_backToRoutines": "← Rutyny",
|
"grooming_backToRoutines": "Rutyny",
|
||||||
"grooming_addEntry": "+ Dodaj wpis",
|
"grooming_addEntry": "+ Dodaj wpis",
|
||||||
"grooming_entryAdded": "Wpis dodany.",
|
"grooming_entryAdded": "Wpis dodany.",
|
||||||
"grooming_entryUpdated": "Wpis zaktualizowany.",
|
"grooming_entryUpdated": "Wpis zaktualizowany.",
|
||||||
|
|
@ -156,7 +166,7 @@
|
||||||
"grooming_daySunday": "Niedziela",
|
"grooming_daySunday": "Niedziela",
|
||||||
|
|
||||||
"suggest_title": "Propozycja rutyny AI",
|
"suggest_title": "Propozycja rutyny AI",
|
||||||
"suggest_backToRoutines": "← Rutyny",
|
"suggest_backToRoutines": "Rutyny",
|
||||||
"suggest_singleTab": "Jedna rutyna",
|
"suggest_singleTab": "Jedna rutyna",
|
||||||
"suggest_batchTab": "Batch / Urlop",
|
"suggest_batchTab": "Batch / Urlop",
|
||||||
"suggest_singleParams": "Parametry",
|
"suggest_singleParams": "Parametry",
|
||||||
|
|
@ -169,6 +179,8 @@
|
||||||
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
|
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
|
||||||
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)",
|
"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_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_generateBtn": "Generuj propozycję",
|
||||||
"suggest_generating": "Generuję…",
|
"suggest_generating": "Generuję…",
|
||||||
"suggest_proposalTitle": "Propozycja",
|
"suggest_proposalTitle": "Propozycja",
|
||||||
|
|
@ -270,6 +282,7 @@
|
||||||
"labResults_flagNone": "Brak",
|
"labResults_flagNone": "Brak",
|
||||||
"labResults_date": "Data *",
|
"labResults_date": "Data *",
|
||||||
"labResults_loincCode": "Kod LOINC *",
|
"labResults_loincCode": "Kod LOINC *",
|
||||||
|
"labResults_loincExample": "np. 718-7",
|
||||||
"labResults_testName": "Nazwa badania",
|
"labResults_testName": "Nazwa badania",
|
||||||
"labResults_testNamePlaceholder": "np. Hemoglobina",
|
"labResults_testNamePlaceholder": "np. Hemoglobina",
|
||||||
"labResults_lab": "Laboratorium",
|
"labResults_lab": "Laboratorium",
|
||||||
|
|
@ -350,7 +363,7 @@
|
||||||
"productForm_aiPrefill": "Uzupełnienie AI",
|
"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_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_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
|
||||||
"productForm_parseWithAI": "Uzupełnij pola (AI)",
|
"productForm_parseWithAI": "Uzupełnij",
|
||||||
"productForm_parsing": "Przetwarzam…",
|
"productForm_parsing": "Przetwarzam…",
|
||||||
"productForm_basicInfo": "Informacje podstawowe",
|
"productForm_basicInfo": "Informacje podstawowe",
|
||||||
"productForm_name": "Nazwa *",
|
"productForm_name": "Nazwa *",
|
||||||
|
|
@ -360,6 +373,7 @@
|
||||||
"productForm_lineName": "Linia / seria",
|
"productForm_lineName": "Linia / seria",
|
||||||
"productForm_lineNamePlaceholder": "np. Hydro Boost",
|
"productForm_lineNamePlaceholder": "np. Hydro Boost",
|
||||||
"productForm_url": "URL",
|
"productForm_url": "URL",
|
||||||
|
"productForm_urlPlaceholder": "https://...",
|
||||||
"productForm_sku": "SKU",
|
"productForm_sku": "SKU",
|
||||||
"productForm_skuPlaceholder": "np. NTR-HB-50",
|
"productForm_skuPlaceholder": "np. NTR-HB-50",
|
||||||
"productForm_barcode": "Kod kreskowy / EAN",
|
"productForm_barcode": "Kod kreskowy / EAN",
|
||||||
|
|
@ -384,11 +398,14 @@
|
||||||
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
|
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
|
||||||
"productForm_ingredients": "Składniki",
|
"productForm_ingredients": "Składniki",
|
||||||
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
|
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
|
||||||
|
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
|
||||||
"productForm_activeIngredients": "Składniki aktywne",
|
"productForm_activeIngredients": "Składniki aktywne",
|
||||||
"productForm_addActive": "+ Dodaj aktywny",
|
"productForm_addActive": "+ Dodaj aktywny",
|
||||||
"productForm_noActives": "Brak składników aktywnych.",
|
"productForm_noActives": "Brak składników aktywnych.",
|
||||||
"productForm_activeName": "Nazwa",
|
"productForm_activeName": "Nazwa",
|
||||||
|
"productForm_activeNamePlaceholder": "np. Niacinamide",
|
||||||
"productForm_activePercent": "%",
|
"productForm_activePercent": "%",
|
||||||
|
"productForm_activePercentPlaceholder": "np. 5",
|
||||||
"productForm_activeStrength": "Siła",
|
"productForm_activeStrength": "Siła",
|
||||||
"productForm_activeIrritation": "Podrażnienie",
|
"productForm_activeIrritation": "Podrażnienie",
|
||||||
"productForm_activeFunctions": "Funkcje",
|
"productForm_activeFunctions": "Funkcje",
|
||||||
|
|
@ -401,6 +418,19 @@
|
||||||
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
|
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
|
||||||
"productForm_productDetails": "Szczegóły produktu",
|
"productForm_productDetails": "Szczegóły produktu",
|
||||||
"productForm_priceTier": "Przedział cenowy",
|
"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_selectTier": "Wybierz przedział",
|
||||||
"productForm_sizeMl": "Rozmiar (ml)",
|
"productForm_sizeMl": "Rozmiar (ml)",
|
||||||
"productForm_fullWeightG": "Waga pełna (g)",
|
"productForm_fullWeightG": "Waga pełna (g)",
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,29 @@
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
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 { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { parseProductText, type ProductParseResponse } from '$lib/api';
|
import { parseProductText, type ProductParseResponse } from '$lib/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
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 ─────────────────────────────────────────────────────
|
// ── Enum option lists ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -162,6 +180,7 @@
|
||||||
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
|
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
|
||||||
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
|
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
|
||||||
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
|
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
|
||||||
|
let personalToleranceNotes = $state(untrack(() => product?.personal_tolerance_notes ?? ''));
|
||||||
|
|
||||||
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
|
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
|
||||||
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
|
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
|
||||||
|
|
@ -170,10 +189,12 @@
|
||||||
|
|
||||||
// ── AI pre-fill state ─────────────────────────────────────────────────────
|
// ── AI pre-fill state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let aiPanelOpen = $state(false);
|
let aiModalOpen = $state(false);
|
||||||
let aiText = $state('');
|
let aiText = $state('');
|
||||||
let aiLoading = $state(false);
|
let aiLoading = $state(false);
|
||||||
let aiError = $state('');
|
let aiError = $state('');
|
||||||
|
let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic');
|
||||||
|
let activesPanelOpen = $state(true);
|
||||||
|
|
||||||
async function parseWithAi() {
|
async function parseWithAi() {
|
||||||
if (!aiText.trim()) return;
|
if (!aiText.trim()) return;
|
||||||
|
|
@ -182,7 +203,7 @@
|
||||||
try {
|
try {
|
||||||
const r = await parseProductText(aiText);
|
const r = await parseProductText(aiText);
|
||||||
applyAiResult(r);
|
applyAiResult(r);
|
||||||
aiPanelOpen = false;
|
aiModalOpen = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiError = (e as Error).message;
|
aiError = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -363,36 +384,157 @@
|
||||||
|
|
||||||
const selectClass =
|
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';
|
'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;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
|
<svelte:window onkeydown={handleModalKeydown} />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<Tabs bind:value={editSection} class="space-y-2">
|
||||||
<button type="button" class="flex w-full items-center justify-between text-left"
|
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
|
||||||
onclick={() => (aiPanelOpen = !aiPanelOpen)}>
|
<TabsTrigger value="basic" class="shrink-0 px-3">{m["productForm_basicInfo"]()}</TabsTrigger>
|
||||||
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
|
<TabsTrigger value="ingredients" class="shrink-0 px-3">{m["productForm_ingredients"]()}</TabsTrigger>
|
||||||
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
<TabsTrigger value="assessment" class="shrink-0 px-3">{m["productForm_effectProfile"]()}</TabsTrigger>
|
||||||
</button>
|
<TabsTrigger value="details" class="shrink-0 px-3">{m["productForm_productDetails"]()}</TabsTrigger>
|
||||||
</CardHeader>
|
<TabsTrigger value="notes" class="shrink-0 px-3">{m["productForm_personalNotes"]()}</TabsTrigger>
|
||||||
{#if aiPanelOpen}
|
</TabsList>
|
||||||
<CardContent class="space-y-3">
|
</Tabs>
|
||||||
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
|
|
||||||
<textarea bind:value={aiText} rows="6"
|
{#if showAiTrigger}
|
||||||
placeholder={m["productForm_pasteText"]()}
|
<div class="flex justify-end">
|
||||||
class={textareaClass}></textarea>
|
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
||||||
{#if aiError}
|
<Sparkles class="size-4" />
|
||||||
<p class="text-sm text-destructive">{aiError}</p>
|
{m["productForm_aiPrefill"]()}
|
||||||
{/if}
|
</Button>
|
||||||
<Button type="button" onclick={parseWithAi}
|
</div>
|
||||||
disabled={aiLoading || !aiText.trim()}>
|
{/if}
|
||||||
{aiLoading ? m["productForm_parsing"]() : m["productForm_parseWithAI"]()}
|
|
||||||
</Button>
|
{#if aiModalOpen}
|
||||||
</CardContent>
|
<button
|
||||||
{/if}
|
type="button"
|
||||||
</Card>
|
class="fixed inset-0 z-50 bg-black/50"
|
||||||
|
onclick={closeAiModal}
|
||||||
|
aria-label={m.common_cancel()}
|
||||||
|
></button>
|
||||||
|
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
|
||||||
|
<Card class="max-h-full w-full overflow-hidden">
|
||||||
|
<CardHeader class="border-b border-border">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
|
||||||
|
<X class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3 overflow-y-auto p-4">
|
||||||
|
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
|
||||||
|
<textarea bind:value={aiText} rows="8" placeholder={m["productForm_pasteText"]()} class={textareaClass}></textarea>
|
||||||
|
{#if aiError}
|
||||||
|
<p class="text-sm text-destructive">{aiError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="button" onclick={parseWithAi} disabled={aiLoading || !aiText.trim()}>
|
||||||
|
{#if aiLoading}
|
||||||
|
{m["productForm_parsing"]()}
|
||||||
|
{:else}
|
||||||
|
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'basic' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
@ -412,7 +554,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="url">{m["productForm_url"]()}</Label>
|
<Label for="url">{m["productForm_url"]()}</Label>
|
||||||
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
|
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
@ -429,7 +571,7 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Classification ────────────────────────────────────────────────────── -->
|
<!-- ── Classification ────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'basic' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
@ -454,8 +596,8 @@
|
||||||
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
|
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="am">AM</SelectItem>
|
<SelectItem value="am">{m.common_am()}</SelectItem>
|
||||||
<SelectItem value="pm">PM</SelectItem>
|
<SelectItem value="pm">{m.common_pm()}</SelectItem>
|
||||||
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
|
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -503,7 +645,7 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
|
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_skinProfile"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_skinProfile"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -569,7 +711,7 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Ingredients ────────────────────────────────────────────────────────── -->
|
<!-- ── Ingredients ────────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_ingredients"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_ingredients"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -578,7 +720,7 @@
|
||||||
id="inci"
|
id="inci"
|
||||||
name="inci"
|
name="inci"
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder="Aqua Glycerin Niacinamide"
|
placeholder={m["productForm_inciPlaceholder"]()}
|
||||||
class={textareaClass}
|
class={textareaClass}
|
||||||
bind:value={inciText}
|
bind:value={inciText}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
@ -586,90 +728,99 @@
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Label>{m["productForm_activeIngredients"]()}</Label>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-md border border-border px-3 py-2 text-left"
|
||||||
|
onclick={() => (activesPanelOpen = !activesPanelOpen)}
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">{m["productForm_activeIngredients"]()}</span>
|
||||||
|
<span class="text-xs text-muted-foreground">{activesPanelOpen ? 'Ukryj' : 'Pokaż'}</span>
|
||||||
|
</button>
|
||||||
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
|
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="actives_json" value={activesJson} />
|
<input type="hidden" name="actives_json" value={activesJson} />
|
||||||
|
|
||||||
{#each actives as active, i}
|
{#if activesPanelOpen}
|
||||||
<div class="rounded-md border border-border p-3 space-y-3">
|
{#each actives as active, i}
|
||||||
<div class="flex items-end gap-2">
|
<div class="rounded-md border border-border p-3 space-y-3">
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="flex items-end gap-2">
|
||||||
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<Input
|
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
|
||||||
placeholder="e.g. Niacinamide"
|
<Input
|
||||||
bind:value={active.name}
|
placeholder={m["productForm_activeNamePlaceholder"]()}
|
||||||
/>
|
bind:value={active.name}
|
||||||
</div>
|
/>
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onclick={() => removeActive(i)}
|
size="sm"
|
||||||
|
onclick={() => removeActive(i)}
|
||||||
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
||||||
>✕</Button>
|
><X class="size-3.5" /></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>
|
||||||
<div class="space-y-1">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<Label class="text-xs">{m["productForm_activeStrength"]()}</Label>
|
<div class="space-y-1">
|
||||||
<select class={selectClass} bind:value={active.strength_level}>
|
<Label class="text-xs">{m["productForm_activePercent"]()}</Label>
|
||||||
<option value="">—</option>
|
<Input
|
||||||
<option value="1">{m["productForm_strengthLow"]()}</option>
|
type="number"
|
||||||
<option value="2">{m["productForm_strengthMedium"]()}</option>
|
min="0"
|
||||||
<option value="3">{m["productForm_strengthHigh"]()}</option>
|
max="100"
|
||||||
</select>
|
step="0.01"
|
||||||
|
placeholder={m["productForm_activePercentPlaceholder"]()}
|
||||||
|
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>
|
||||||
<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">
|
<div class="space-y-1">
|
||||||
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
||||||
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
||||||
{#each ingFunctions as fn}
|
{#each ingFunctions as fn}
|
||||||
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
|
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={active.functions.includes(fn)}
|
checked={active.functions.includes(fn)}
|
||||||
onchange={() => toggleFn(i, fn)}
|
onchange={() => toggleFn(i, fn)}
|
||||||
class="rounded border-input"
|
class="rounded border-input"
|
||||||
/>
|
/>
|
||||||
{ingFunctionLabels[fn]}
|
{ingFunctionLabels[fn]}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if actives.length === 0}
|
{#if actives.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">{m["productForm_noActives"]()}</p>
|
<p class="text-sm text-muted-foreground">{m["productForm_noActives"]()}</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
|
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
|
@ -694,7 +845,7 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
@ -765,49 +916,70 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Product details ────────────────────────────────────────────────────── -->
|
<!-- ── Product details ────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'details' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
|
||||||
<div class="space-y-2">
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
<Label for="price_amount">Price</Label>
|
<div class="space-y-2">
|
||||||
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder="e.g. 79.99" bind:value={priceAmount} />
|
<Label for="price_amount">{m["productForm_price"]()}</Label>
|
||||||
|
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder={m["productForm_priceAmountPlaceholder"]()} bind:value={priceAmount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="price_currency">{m["productForm_currency"]()}</Label>
|
||||||
|
<Input id="price_currency" name="price_currency" maxlength={3} placeholder={m["productForm_priceCurrencyPlaceholder"]()} 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={m["productForm_sizePlaceholder"]()} 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={m["productForm_fullWeightPlaceholder"]()} 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={m["productForm_emptyWeightPlaceholder"]()} 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={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
|
||||||
<Label for="price_currency">Currency</Label>
|
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||||
<Input id="price_currency" name="price_currency" maxlength={3} placeholder="PLN" bind:value={priceCurrency} />
|
<div class="space-y-2">
|
||||||
</div>
|
<div>
|
||||||
|
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
|
||||||
<div class="space-y-2">
|
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
|
||||||
<Label for="size_ml">{m["productForm_sizeMl"]()}</Label>
|
</div>
|
||||||
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" bind:value={sizeMl} />
|
<div>
|
||||||
</div>
|
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
|
||||||
|
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
|
||||||
<div class="space-y-2">
|
</div>
|
||||||
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
|
<div>
|
||||||
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 120" bind:value={fullWeightG} />
|
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
|
||||||
</div>
|
<p class="font-medium">{computedPriceTierLabel ?? 'n/a'}</p>
|
||||||
|
</div>
|
||||||
<div class="space-y-2">
|
</div>
|
||||||
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
|
</div>
|
||||||
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 30" bind:value={emptyWeightG} />
|
{/if}
|
||||||
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
|
<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} />
|
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="ph_max">{m["productForm_phMax"]()}</Label>
|
<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} />
|
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -826,7 +998,7 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
|
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
@ -886,17 +1058,17 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
|
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'details' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
|
<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} />
|
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="max_frequency_per_week">{m["productForm_maxFrequencyPerWeek"]()}</Label>
|
<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} />
|
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -925,13 +1097,13 @@
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label>
|
<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} />
|
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
|
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
|
||||||
<Card>
|
<Card class={editSection === 'notes' ? '' : 'hidden'}>
|
||||||
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -959,7 +1131,8 @@
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
|
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
|
||||||
class={textareaClass}
|
class={textareaClass}
|
||||||
>{product?.personal_tolerance_notes ?? ''}</textarea>
|
bind:value={personalToleranceNotes}
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,30 @@
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||||
|
import {
|
||||||
|
House,
|
||||||
|
Package,
|
||||||
|
ClipboardList,
|
||||||
|
Scissors,
|
||||||
|
Pill,
|
||||||
|
FlaskConical,
|
||||||
|
Sparkles,
|
||||||
|
Menu,
|
||||||
|
X
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
|
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
||||||
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
|
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
|
||||||
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
|
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
|
||||||
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
|
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
|
||||||
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
|
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
|
||||||
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
|
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical },
|
||||||
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
|
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
|
|
@ -41,12 +52,12 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
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"
|
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}
|
{#if mobileMenuOpen}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<X class="size-[18px]" />
|
||||||
{:else}
|
{:else}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
<Menu class="size-[18px]" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -60,7 +71,7 @@
|
||||||
onclick={() => (mobileMenuOpen = false)}
|
onclick={() => (mobileMenuOpen = false)}
|
||||||
aria-label={m.common_cancel()}
|
aria-label={m.common_cancel()}
|
||||||
></button>
|
></button>
|
||||||
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
|
<!-- Drawer (same z-50 but later in DOM, on top) -->
|
||||||
<nav
|
<nav
|
||||||
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
|
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -79,7 +90,7 @@
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||||
>
|
>
|
||||||
<span class="text-base">{item.icon}</span>
|
<item.icon class="size-4 shrink-0" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -107,7 +118,7 @@
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||||
>
|
>
|
||||||
<span class="text-base">{item.icon}</span>
|
<item.icon class="size-4 shrink-0" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
<Input id="collected_at" name="collected_at" type="date" required />
|
<Input id="collected_at" name="collected_at" type="date" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">(e.g. 718-7)</span></Label>
|
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">({m["labResults_loincExample"]()})</span></Label>
|
||||||
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Sparkles, ArrowUp, ArrowDown } from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -18,7 +20,11 @@
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
type OwnershipFilter = 'all' | 'owned' | 'unowned';
|
type OwnershipFilter = 'all' | 'owned' | 'unowned';
|
||||||
|
type SortKey = 'brand' | 'name' | 'time' | 'price';
|
||||||
let ownershipFilter = $state<OwnershipFilter>('all');
|
let ownershipFilter = $state<OwnershipFilter>('all');
|
||||||
|
let sortKey = $state<SortKey>('brand');
|
||||||
|
let sortDirection = $state<'asc' | 'desc'>('asc');
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
||||||
|
|
@ -29,14 +35,59 @@
|
||||||
return p.inventory?.some(inv => !inv.finished_at) ?? false;
|
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((() => {
|
const groupedProducts = $derived((() => {
|
||||||
let items = data.products;
|
let items = data.products;
|
||||||
if (ownershipFilter === 'owned') items = items.filter(isOwned);
|
if (ownershipFilter === 'owned') items = items.filter(isOwned);
|
||||||
if (ownershipFilter === 'unowned') items = items.filter(p => !isOwned(p));
|
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) => {
|
items = [...items].sort((a, b) => {
|
||||||
const bc = a.brand.localeCompare(b.brand);
|
let cmp = 0;
|
||||||
return bc !== 0 ? bc : a.name.localeCompare(b.name);
|
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<string, Product[]>();
|
const map = new SvelteMap<string, Product[]>();
|
||||||
|
|
@ -58,11 +109,15 @@
|
||||||
|
|
||||||
function formatPricePerUse(value?: number): string {
|
function formatPricePerUse(value?: number): string {
|
||||||
if (value == null) return '-';
|
if (value == null) return '-';
|
||||||
return `${value.toFixed(2)} PLN/use`;
|
return `${value.toFixed(2)} ${m.common_pricePerUse()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTier(value?: string): string {
|
function formatTier(value?: string): string {
|
||||||
if (!value) return 'n/a';
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,16 +125,6 @@
|
||||||
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
|
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';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
||||||
|
|
@ -91,7 +136,7 @@
|
||||||
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button href={resolve('/products/suggest')} variant="outline">✨ {m["products_suggest"]()}</Button>
|
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
|
||||||
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,16 +153,44 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="w-full lg:max-w-md">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
|
||||||
|
{m['products_colBrand']()}
|
||||||
|
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
|
||||||
|
{m['products_colName']()}
|
||||||
|
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
|
||||||
|
{m['products_colTime']()}
|
||||||
|
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
|
||||||
|
{m["products_colPricePerUse"]()}
|
||||||
|
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: table -->
|
<!-- Desktop: table -->
|
||||||
<div class="hidden rounded-md border border-border md:block">
|
<div class="hidden rounded-md border border-border md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{m["products_colName"]()}</TableHead>
|
<TableHead>{m["products_colName"]()}</TableHead>
|
||||||
<TableHead>{m["products_colBrand"]()}</TableHead>
|
<TableHead>{m["products_colBrand"]()}</TableHead>
|
||||||
<TableHead>{m["products_colTargets"]()}</TableHead>
|
<TableHead>{m["products_colTargets"]()}</TableHead>
|
||||||
<TableHead>{m["products_colTime"]()}</TableHead>
|
<TableHead>{m["products_colTime"]()}</TableHead>
|
||||||
<TableHead>Pricing</TableHead>
|
<TableHead>{m["products_colPricePerUse"]()}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -135,9 +208,9 @@
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
<TableRow class="cursor-pointer hover:bg-muted/50">
|
<TableRow class={`cursor-pointer ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/25 text-muted-foreground hover:bg-muted/35'}`}>
|
||||||
<TableCell>
|
<TableCell class="max-w-[32rem] align-top whitespace-normal">
|
||||||
<a href={resolve(`/products/${product.id}`)} class="font-medium hover:underline">
|
<a href={resolve(`/products/${product.id}`)} title={product.name} class="block break-words line-clamp-2 font-medium hover:underline">
|
||||||
{product.name}
|
{product.name}
|
||||||
</a>
|
</a>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -156,8 +229,7 @@
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
|
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
|
||||||
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
|
<Badge variant="outline" class="text-[10px]">{formatTier(product.price_tier)}</Badge>
|
||||||
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -180,16 +252,15 @@
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/products/${product.id}`)}
|
href={resolve(`/products/${product.id}`)}
|
||||||
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
|
class={`block rounded-lg border border-border p-4 ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="font-medium">{product.name}</p>
|
<p class="break-words line-clamp-2 font-medium" title={product.name}>{product.name}</p>
|
||||||
<p class="text-sm text-muted-foreground">{product.brand}</p>
|
<p class="text-sm text-muted-foreground">{product.brand}</p>
|
||||||
<div class="mt-1 flex items-center gap-2 text-xs">
|
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||||
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
|
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
|
||||||
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
|
<Badge variant="outline" class="text-[10px]">{formatTier(product.price_tier)}</Badge>
|
||||||
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
|
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
|
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte';
|
||||||
import ProductForm from '$lib/components/ProductForm.svelte';
|
import ProductForm from '$lib/components/ProductForm.svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
@ -16,6 +17,10 @@
|
||||||
|
|
||||||
let showInventoryForm = $state(false);
|
let showInventoryForm = $state(false);
|
||||||
let editingInventoryId = $state<string | null>(null);
|
let editingInventoryId = $state<string | null>(null);
|
||||||
|
let activeTab = $state<'inventory' | 'edit'>('edit');
|
||||||
|
let isEditDirty = $state(false);
|
||||||
|
let editSaveVersion = $state(0);
|
||||||
|
let productFormRef: { openAiModal: () => void } | null = $state(null);
|
||||||
|
|
||||||
function formatAmount(amount?: number, currency?: string): string {
|
function formatAmount(amount?: number, currency?: string): string {
|
||||||
if (amount == null || !currency) return '-';
|
if (amount == null || !currency) return '-';
|
||||||
|
|
@ -32,7 +37,7 @@
|
||||||
|
|
||||||
function formatPricePerUse(value?: number): string {
|
function formatPricePerUse(value?: number): string {
|
||||||
if (value == null) return '-';
|
if (value == null) return '-';
|
||||||
return `${value.toFixed(2)} PLN/use`;
|
return `${value.toFixed(2)} ${m.common_pricePerUse()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPriceAmount(): number | undefined {
|
function getPriceAmount(): number | undefined {
|
||||||
|
|
@ -47,278 +52,298 @@
|
||||||
return (product as { price_per_use_pln?: number }).price_per_use_pln;
|
return (product as { price_per_use_pln?: number }).price_per_use_pln;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTierSource(): string {
|
function formatTier(value?: string): string {
|
||||||
const source = (product as { price_tier_source?: string }).price_tier_source;
|
if (!value) return 'n/a';
|
||||||
if (source === 'fallback') return 'fallback';
|
if (value === 'budget') return m['productForm_priceBudget']();
|
||||||
if (source === 'insufficient_data') return 'insufficient_data';
|
if (value === 'mid') return m['productForm_priceMid']();
|
||||||
return 'category';
|
if (value === 'premium') return m['productForm_pricePremium']();
|
||||||
|
if (value === 'luxury') return m['productForm_priceLuxury']();
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="fixed inset-x-3 bottom-3 z-40 flex items-center justify-end gap-2 rounded-lg border border-border bg-card/95 p-2 shadow-sm backdrop-blur md:inset-x-auto md:bottom-auto md:right-6 md:top-4">
|
||||||
<div>
|
<Button
|
||||||
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
type="submit"
|
||||||
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
|
form="product-edit-form"
|
||||||
|
disabled={activeTab !== 'edit' || !isEditDirty}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Save class="size-4" aria-hidden="true" />
|
||||||
|
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span>
|
||||||
|
</Button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => {
|
||||||
|
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
<span class="sr-only md:not-sr-only">{m["products_deleteProduct"]()}</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6 pb-20 md:pb-0">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
|
<div class="flex flex-wrap items-start gap-3">
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<h2 class="break-words text-2xl font-bold tracking-tight">{product.name}</h2>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span class="font-medium text-foreground">{product.brand}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span class="uppercase">{product.recommended_time}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{product.category.replace(/_/g, ' ')}</span>
|
||||||
|
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
|
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m.common_saved()}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Card>
|
<Tabs bind:value={activeTab} class="space-y-2">
|
||||||
<CardContent class="pt-4">
|
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
|
||||||
<div class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
|
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
|
||||||
<div>
|
<Boxes class="size-4" aria-hidden="true" />
|
||||||
<p class="text-muted-foreground">Price</p>
|
<span class="sr-only md:not-sr-only">{m.inventory_title({ count: product.inventory.length })}</span>
|
||||||
<p class="font-medium">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
|
</TabsTrigger>
|
||||||
</div>
|
<TabsTrigger value="edit" class="shrink-0 px-3" title={m.common_edit()}>
|
||||||
<div>
|
<Pencil class="size-4" aria-hidden="true" />
|
||||||
<p class="text-muted-foreground">Price per use</p>
|
<span class="sr-only md:not-sr-only">{m.common_edit()}</span>
|
||||||
<p class="font-medium">{formatPricePerUse(getPricePerUse())}</p>
|
</TabsTrigger>
|
||||||
</div>
|
</TabsList>
|
||||||
<div>
|
|
||||||
<p class="text-muted-foreground">Price tier</p>
|
<TabsContent value="inventory" class="space-y-4 pt-2">
|
||||||
<p class="font-medium uppercase">{product.price_tier ?? 'n/a'}</p>
|
<div class="space-y-3">
|
||||||
<p class="text-xs text-muted-foreground">source: {getTierSource()}</p>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
|
||||||
|
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if form?.inventoryAdded}
|
||||||
|
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageAdded"]()}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.inventoryUpdated}
|
||||||
|
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageUpdated"]()}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.inventoryDeleted}
|
||||||
|
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageDeleted"]()}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showInventoryForm}
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-2 flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
|
||||||
|
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
||||||
|
<Input id="add_opened_at" name="opened_at" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
|
||||||
|
<Input id="add_finished_at" name="finished_at" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
|
||||||
|
<Input id="add_expiry_date" name="expiry_date" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
|
||||||
|
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
|
||||||
|
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="add_notes">{m.inventory_notes()}</Label>
|
||||||
|
<Input id="add_notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button type="submit" size="sm">{m.common_add()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if product.inventory.length}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each product.inventory as pkg (pkg.id)}
|
||||||
|
<div class="rounded-md border border-border text-sm">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
||||||
|
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
|
||||||
|
</Badge>
|
||||||
|
{#if pkg.finished_at}
|
||||||
|
<Badge variant="outline">{m["inventory_badgeFinished"]()}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.expiry_date}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_exp()} {pkg.expiry_date.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.opened_at}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_opened()} {pkg.opened_at.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.finished_at}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.current_weight_g}
|
||||||
|
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.last_weighed_at}
|
||||||
|
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.notes}
|
||||||
|
<span class="text-muted-foreground">{pkg.notes}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
|
||||||
|
>
|
||||||
|
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
|
||||||
|
</Button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteInventory"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="inventory_id" value={pkg.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editingInventoryId === pkg.id}
|
||||||
|
<div class="border-t px-4 py-3">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateInventory"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') editingInventoryId = null;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="inventory_id" value={pkg.id} />
|
||||||
|
<div class="sm:col-span-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit_is_opened_{pkg.id}"
|
||||||
|
name="is_opened"
|
||||||
|
value="true"
|
||||||
|
checked={pkg.is_opened}
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
|
||||||
|
<Input id="edit_opened_at_{pkg.id}" name="opened_at" type="date" value={pkg.opened_at?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
|
||||||
|
<Input id="edit_finished_at_{pkg.id}" name="finished_at" type="date" value={pkg.finished_at?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
|
||||||
|
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
|
||||||
|
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
|
||||||
|
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
||||||
|
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<Button type="submit" size="sm">{m.common_save()}</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onclick={() => (editingInventoryId = null)}>{m.common_cancel()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Edit form -->
|
<TabsContent value="edit" class="space-y-4 pt-2">
|
||||||
<form method="POST" action="?/update" use:enhance class="space-y-6">
|
|
||||||
<ProductForm {product} />
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<Button type="submit">{m["products_saveChanges"]()}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Inventory -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
|
|
||||||
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
|
|
||||||
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.inventoryAdded}
|
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageAdded"]()}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.inventoryUpdated}
|
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageUpdated"]()}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.inventoryDeleted}
|
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageDeleted"]()}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showInventoryForm}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="pt-4">
|
<CardHeader class="pb-2">
|
||||||
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="col-span-2 flex items-center gap-2">
|
<CardTitle class="text-base">{m.common_edit()}</CardTitle>
|
||||||
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
|
<Button type="button" variant="outline" size="sm" onclick={() => productFormRef?.openAiModal()}>
|
||||||
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
|
<Sparkles class="size-4" />
|
||||||
</div>
|
{m["productForm_aiPrefill"]()}
|
||||||
<div class="space-y-1">
|
</Button>
|
||||||
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
|
</div>
|
||||||
<Input id="add_opened_at" name="opened_at" type="date" />
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
<div class="space-y-1">
|
<form
|
||||||
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
|
id="product-edit-form"
|
||||||
<Input id="add_finished_at" name="finished_at" type="date" />
|
method="POST"
|
||||||
</div>
|
action="?/update"
|
||||||
<div class="space-y-1">
|
use:enhance={() => {
|
||||||
<Label for="expiry_date">{m["inventory_expiryDate"]()}</Label>
|
return async ({ result, update }) => {
|
||||||
<Input id="expiry_date" name="expiry_date" type="date" />
|
await update();
|
||||||
</div>
|
if (result.type === 'success') {
|
||||||
<div class="space-y-1">
|
isEditDirty = false;
|
||||||
<Label for="current_weight_g">{m["inventory_currentWeight"]()}</Label>
|
editSaveVersion += 1;
|
||||||
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
|
}
|
||||||
</div>
|
};
|
||||||
<div class="space-y-1">
|
}}
|
||||||
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
|
class="space-y-6"
|
||||||
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
|
>
|
||||||
</div>
|
<ProductForm
|
||||||
<div class="space-y-1">
|
bind:this={productFormRef}
|
||||||
<Label for="notes">{m.inventory_notes()}</Label>
|
{product}
|
||||||
<Input id="notes" name="notes" />
|
bind:dirty={isEditDirty}
|
||||||
</div>
|
saveVersion={editSaveVersion}
|
||||||
<div class="flex items-end">
|
showAiTrigger={false}
|
||||||
<Button type="submit" size="sm">{m.common_add()}</Button>
|
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
|
||||||
</div>
|
computedPricePerUseLabel={formatPricePerUse(getPricePerUse())}
|
||||||
|
computedPriceTierLabel={formatTier(product.price_tier)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
{#if product.inventory.length}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each product.inventory as pkg (pkg.id)}
|
|
||||||
<div class="rounded-md border border-border text-sm">
|
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
|
||||||
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
|
|
||||||
</Badge>
|
|
||||||
{#if pkg.finished_at}
|
|
||||||
<Badge variant="outline">{m["inventory_badgeFinished"]()}</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.expiry_date}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_exp()} {pkg.expiry_date.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.opened_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_opened()} {pkg.opened_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.finished_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.current_weight_g}
|
|
||||||
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.last_weighed_at}
|
|
||||||
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if pkg.notes}
|
|
||||||
<span class="text-muted-foreground">{pkg.notes}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
|
|
||||||
>
|
|
||||||
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
|
|
||||||
</Button>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteInventory"
|
|
||||||
use:enhance
|
|
||||||
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="inventory_id" value={pkg.id} />
|
|
||||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">×</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if editingInventoryId === pkg.id}
|
|
||||||
<div class="border-t px-4 py-3">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updateInventory"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
await update();
|
|
||||||
if (result.type === 'success') editingInventoryId = null;
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="grid grid-cols-2 gap-4"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="inventory_id" value={pkg.id} />
|
|
||||||
<div class="col-span-2 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="edit_is_opened_{pkg.id}"
|
|
||||||
name="is_opened"
|
|
||||||
value="true"
|
|
||||||
checked={pkg.is_opened}
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_opened_at_{pkg.id}"
|
|
||||||
name="opened_at"
|
|
||||||
type="date"
|
|
||||||
value={pkg.opened_at?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_finished_at_{pkg.id}"
|
|
||||||
name="finished_at"
|
|
||||||
type="date"
|
|
||||||
value={pkg.finished_at?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_expiry_{pkg.id}"
|
|
||||||
name="expiry_date"
|
|
||||||
type="date"
|
|
||||||
value={pkg.expiry_date?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_weight_{pkg.id}"
|
|
||||||
name="current_weight_g"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={pkg.current_weight_g ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="edit_last_weighed_{pkg.id}"
|
|
||||||
name="last_weighed_at"
|
|
||||||
type="date"
|
|
||||||
value={pkg.last_weighed_at?.slice(0, 10) ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
|
|
||||||
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end gap-2">
|
|
||||||
<Button type="submit" size="sm">{m.common_save()}</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (editingInventoryId = null)}
|
|
||||||
>{m.common_cancel()}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Danger zone -->
|
|
||||||
<div>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/delete"
|
|
||||||
use:enhance
|
|
||||||
onsubmit={(e) => {
|
|
||||||
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import type { ActionData } from './$types';
|
import type { ActionData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
import ProductForm from '$lib/components/ProductForm.svelte';
|
import ProductForm from '$lib/components/ProductForm.svelte';
|
||||||
|
|
||||||
let { form }: { form: ActionData } = $props();
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
@ -21,7 +22,7 @@
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Sparkles, ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
let suggestions = $state<ProductSuggestion[] | null>(null);
|
let suggestions = $state<ProductSuggestion[] | null>(null);
|
||||||
let reasoning = $state('');
|
let reasoning = $state('');
|
||||||
|
|
@ -33,7 +34,7 @@
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -53,7 +54,7 @@
|
||||||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||||
{m["products_suggestGenerating"]()}
|
{m["products_suggestGenerating"]()}
|
||||||
{:else}
|
{:else}
|
||||||
✨ {m["products_suggestBtn"]()}
|
<Sparkles class="size-4" /> {m["products_suggestBtn"]()}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
} from '$lib/components/ui/select';
|
} from '$lib/components/ui/select';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { routine, products } = $derived(data);
|
let { routine, products } = $derived(data);
|
||||||
|
|
@ -142,7 +143,7 @@
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a>
|
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
{routine.part_of_day.toUpperCase()}
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
|
@ -272,7 +273,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Action step: change action_type / notes -->
|
<!-- Action step: change action_type / notes -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>Action</Label>
|
<Label>{m["routines_action"]()}</Label>
|
||||||
<Select
|
<Select
|
||||||
type="single"
|
type="single"
|
||||||
value={editDraft.action_type ?? ''}
|
value={editDraft.action_type ?? ''}
|
||||||
|
|
@ -280,7 +281,7 @@
|
||||||
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
|
{editDraft.action_type?.replace(/_/g, ' ') ?? m["routines_selectAction"]()}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each GROOMING_ACTIONS as action (action)}
|
{#each GROOMING_ACTIONS as action (action)}
|
||||||
|
|
@ -290,11 +291,11 @@
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>Notes</Label>
|
<Label>{m.routines_notes()}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={editDraft.action_notes ?? ''}
|
value={editDraft.action_notes ?? ''}
|
||||||
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
|
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
|
||||||
placeholder="optional notes"
|
placeholder={m["routines_actionNotesPlaceholder"]()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -318,8 +319,8 @@
|
||||||
<span
|
<span
|
||||||
use:dragHandle
|
use:dragHandle
|
||||||
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
|
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
|
||||||
aria-label="drag to reorder"
|
aria-label={m.common_dragToReorder()}
|
||||||
>⋮⋮</span>
|
><GripVertical class="size-4" /></span>
|
||||||
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
|
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
|
||||||
<div class="flex-1 min-w-0 px-1">
|
<div class="flex-1 min-w-0 px-1">
|
||||||
{#if step.product_id}
|
{#if step.product_id}
|
||||||
|
|
@ -340,16 +341,16 @@
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
onclick={() => startEdit(step)}
|
onclick={() => startEdit(step)}
|
||||||
aria-label="edit step"
|
aria-label={m.common_editStep()}
|
||||||
>✎</Button>
|
><Pencil class="size-4" /></Button>
|
||||||
<form method="POST" action="?/removeStep" use:enhance>
|
<form method="POST" action="?/removeStep" use:enhance>
|
||||||
<input type="hidden" name="step_id" value={step.id} />
|
<input type="hidden" name="step_id" value={step.id} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
|
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
>×</Button>
|
><X class="size-4" /></Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
import type { GroomingAction, GroomingSchedule } from '$lib/types';
|
import type { GroomingAction, GroomingSchedule } from '$lib/types';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
@ -48,7 +49,7 @@
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["grooming_backToRoutines"]()}</a>
|
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
||||||
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2>
|
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
|
|
||||||
<div class="max-w-md space-y-6">
|
<div class="max-w-md space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a>
|
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -39,8 +40,8 @@
|
||||||
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
|
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
|
||||||
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="am">AM</SelectItem>
|
<SelectItem value="am">{m.common_am()}</SelectItem>
|
||||||
<SelectItem value="pm">PM</SelectItem>
|
<SelectItem value="pm">{m.common_pm()}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
|
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
|
@ -105,7 +106,7 @@
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
|
<a href={resolve('/routines')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -316,8 +317,8 @@
|
||||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
|
<Label for="batch_minimize_products" class="font-medium">{m["suggest_minimizeProductsLabel"]()}</Label>
|
||||||
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
|
<p class="text-xs text-muted-foreground">{m["suggest_minimizeProductsHint"]()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -359,9 +360,13 @@
|
||||||
<span class="font-medium text-sm">{day.date}</span>
|
<span class="font-medium text-sm">{day.date}</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
AM {day.am_steps.length} {m["suggest_amSteps"]()} · PM {day.pm_steps.length} {m["suggest_pmSteps"]()}
|
{m.common_am()} {day.am_steps.length} {m["suggest_amSteps"]()} · {m.common_pm()} {day.pm_steps.length} {m["suggest_pmSteps"]()}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground">{isOpen ? '▲' : '▼'}</span>
|
{#if isOpen}
|
||||||
|
<ChevronUp class="size-4 text-muted-foreground" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown class="size-4 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -374,7 +379,7 @@
|
||||||
<!-- AM steps -->
|
<!-- AM steps -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge>AM</Badge>
|
<Badge>{m.common_am()}</Badge>
|
||||||
<span class="text-xs text-muted-foreground">{day.am_steps.length} {m["suggest_amSteps"]()}</span>
|
<span class="text-xs text-muted-foreground">{day.am_steps.length} {m["suggest_amSteps"]()}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if day.am_steps.length}
|
{#if day.am_steps.length}
|
||||||
|
|
@ -399,7 +404,7 @@
|
||||||
<!-- PM steps -->
|
<!-- PM steps -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant="secondary">PM</Badge>
|
<Badge variant="secondary">{m.common_pm()}</Badge>
|
||||||
<span class="text-xs text-muted-foreground">{day.pm_steps.length} {m["suggest_pmSteps"]()}</span>
|
<span class="text-xs text-muted-foreground">{day.pm_steps.length} {m["suggest_pmSteps"]()}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if day.pm_steps.length}
|
{#if day.pm_steps.length}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
import { Sparkles, Pencil, X } from 'lucide-svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI photo analysis state
|
// AI photo analysis state
|
||||||
let aiPanelOpen = $state(false);
|
let aiModalOpen = $state(false);
|
||||||
let selectedFiles = $state<File[]>([]);
|
let selectedFiles = $state<File[]>([]);
|
||||||
let previewUrls = $state<string[]>([]);
|
let previewUrls = $state<string[]>([]);
|
||||||
let aiLoading = $state(false);
|
let aiLoading = $state(false);
|
||||||
|
|
@ -137,16 +138,35 @@
|
||||||
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
|
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
|
||||||
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
|
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
|
||||||
if (r.notes) notes = r.notes;
|
if (r.notes) notes = r.notes;
|
||||||
aiPanelOpen = false;
|
aiModalOpen = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiError = (e as Error).message;
|
aiError = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
aiLoading = false;
|
aiLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
|
||||||
|
<svelte:window onkeydown={handleModalKeydown} />
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|
@ -154,9 +174,17 @@
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
|
||||||
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
|
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
<div class="flex items-center gap-2">
|
||||||
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
{#if showForm}
|
||||||
</Button>
|
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
||||||
|
<Sparkles class="size-4" />
|
||||||
|
{m["skin_aiAnalysisTitle"]()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
|
|
@ -173,52 +201,52 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<!-- AI photo analysis card -->
|
{#if aiModalOpen}
|
||||||
<Card>
|
<button
|
||||||
<CardHeader>
|
type="button"
|
||||||
<button
|
class="fixed inset-0 z-50 bg-black/50"
|
||||||
type="button"
|
onclick={closeAiModal}
|
||||||
class="flex w-full items-center justify-between text-left"
|
aria-label={m.common_cancel()}
|
||||||
onclick={() => (aiPanelOpen = !aiPanelOpen)}
|
></button>
|
||||||
>
|
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
|
||||||
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
|
<Card class="max-h-full w-full overflow-hidden">
|
||||||
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
<CardHeader class="border-b border-border">
|
||||||
</button>
|
<div class="flex items-center justify-between gap-3">
|
||||||
</CardHeader>
|
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
|
||||||
{#if aiPanelOpen}
|
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
|
||||||
<CardContent class="space-y-3">
|
<X class="size-4" />
|
||||||
<p class="text-sm text-muted-foreground">
|
</Button>
|
||||||
{m["skin_aiUploadText"]()}
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
|
|
||||||
multiple
|
|
||||||
onchange={handleFileSelect}
|
|
||||||
class="block w-full text-sm text-muted-foreground
|
|
||||||
file:mr-4 file:rounded-md file:border-0 file:bg-primary
|
|
||||||
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
|
||||||
/>
|
|
||||||
{#if previewUrls.length}
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each previewUrls as url (url)}
|
|
||||||
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</CardHeader>
|
||||||
{#if aiError}
|
<CardContent class="space-y-3 overflow-y-auto p-4">
|
||||||
<p class="text-sm text-destructive">{aiError}</p>
|
<p class="text-sm text-muted-foreground">{m["skin_aiUploadText"]()}</p>
|
||||||
{/if}
|
<input
|
||||||
<Button
|
type="file"
|
||||||
type="button"
|
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
|
||||||
onclick={analyzePhotos}
|
multiple
|
||||||
disabled={aiLoading || !selectedFiles.length}
|
onchange={handleFileSelect}
|
||||||
>
|
class="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
||||||
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
|
/>
|
||||||
</Button>
|
{#if previewUrls.length}
|
||||||
</CardContent>
|
<div class="flex flex-wrap gap-2">
|
||||||
{/if}
|
{#each previewUrls as url (url)}
|
||||||
</Card>
|
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if aiError}
|
||||||
|
<p class="text-sm text-destructive">{aiError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
|
||||||
|
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- New snapshot form -->
|
<!-- New snapshot form -->
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -426,10 +454,10 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium">{snap.snapshot_date}</span>
|
<span class="font-medium">{snap.snapshot_date}</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
|
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}><Pencil class="size-4" /></Button>
|
||||||
<form method="POST" action="?/delete" use:enhance>
|
<form method="POST" action="?/delete" use:enhance>
|
||||||
<input type="hidden" name="id" value={snap.id} />
|
<input type="hidden" name="id" value={snap.id} />
|
||||||
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
|
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}><X class="size-4" /></Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,19 @@ import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||||
|
|
||||||
|
const stripDeprecatedRollupOptions = {
|
||||||
|
name: 'strip-deprecated-rollup-options',
|
||||||
|
outputOptions(options: Record<string, unknown>) {
|
||||||
|
if ('codeSplitting' in options) {
|
||||||
|
delete options.codeSplitting;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
stripDeprecatedRollupOptions,
|
||||||
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }),
|
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
sveltekit()
|
sveltekit()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue