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:
Piotr Oleszczyk 2026-03-04 18:13:49 +01:00
parent 83ba4cc5c0
commit d4fbc1faf5
15 changed files with 921 additions and 532 deletions

View file

@ -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)",

View file

@ -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)",

View file

@ -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&#10;Glycerin&#10;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>

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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)}>

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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()