feat(frontend): unify editorial UI and DRY form architecture

This commit is contained in:
Piotr Oleszczyk 2026-03-04 21:43:37 +01:00
parent d4fbc1faf5
commit 693c6a9626
35 changed files with 2600 additions and 1180 deletions

View file

@ -5,26 +5,42 @@
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--background: hsl(42 35% 95%);
--foreground: hsl(220 24% 14%);
--card: hsl(44 32% 96%);
--card-foreground: hsl(220 24% 14%);
--popover: hsl(44 32% 96%);
--popover-foreground: hsl(220 24% 14%);
--primary: hsl(15 44% 34%);
--primary-foreground: hsl(42 40% 97%);
--secondary: hsl(38 24% 91%);
--secondary-foreground: hsl(220 20% 20%);
--muted: hsl(42 20% 90%);
--muted-foreground: hsl(219 12% 39%);
--accent: hsl(42 24% 90%);
--accent-foreground: hsl(220 24% 14%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 5.9% 10%);
--border: hsl(35 23% 76%);
--input: hsl(37 20% 80%);
--ring: hsl(15 40% 38%);
--radius: 0.5rem;
--editorial-paper: hsl(48 37% 96%);
--editorial-paper-strong: hsl(44 43% 92%);
--editorial-ink: hsl(220 23% 14%);
--editorial-muted: hsl(219 12% 39%);
--editorial-line: hsl(36 24% 74%);
--accent-dashboard: hsl(13 45% 39%);
--accent-products: hsl(95 28% 33%);
--accent-routines: hsl(186 27% 33%);
--accent-skin: hsl(16 51% 44%);
--accent-health-labs: hsl(212 41% 39%);
--accent-health-meds: hsl(140 31% 33%);
--page-accent: var(--accent-dashboard);
--page-accent-soft: hsl(24 42% 89%);
}
.dark {
@ -86,4 +102,723 @@
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Manrope', 'Segoe UI', sans-serif;
background-image:
radial-gradient(circle at 8% 4%, hsl(34 48% 90% / 0.62), transparent 36%),
linear-gradient(hsl(42 26% 95%), hsl(40 20% 93%));
}
.app-shell {
--page-accent: var(--accent-dashboard);
--page-accent-soft: hsl(18 40% 89%);
display: flex;
min-height: 100vh;
flex-direction: column;
}
.domain-dashboard {
--page-accent: var(--accent-dashboard);
--page-accent-soft: hsl(18 40% 89%);
}
.domain-products {
--page-accent: var(--accent-products);
--page-accent-soft: hsl(95 28% 89%);
}
.domain-routines {
--page-accent: var(--accent-routines);
--page-accent-soft: hsl(186 28% 88%);
}
.domain-skin {
--page-accent: var(--accent-skin);
--page-accent-soft: hsl(20 52% 88%);
}
.domain-health-labs {
--page-accent: var(--accent-health-labs);
--page-accent-soft: hsl(208 38% 88%);
}
.domain-health-meds {
--page-accent: var(--accent-health-meds);
--page-accent-soft: hsl(135 28% 88%);
}
.app-mobile-header {
border-bottom: 1px solid hsl(35 22% 76% / 0.7);
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(44 25% 94%));
}
.app-mobile-title,
.app-brand {
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.app-icon-button {
display: flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border: 1px solid hsl(34 21% 75%);
border-radius: 0.45rem;
color: var(--muted-foreground);
}
.app-icon-button:hover {
color: var(--foreground);
border-color: var(--page-accent);
background: var(--page-accent-soft);
}
.app-sidebar {
border-right: 1px solid hsl(36 20% 73% / 0.75);
background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%));
}
.app-sidebar a {
border: 1px solid transparent;
}
.app-sidebar a:hover {
border-color: hsl(35 23% 76% / 0.75);
}
.app-sidebar a.bg-accent {
border-color: color-mix(in srgb, var(--page-accent) 45%, white);
background: color-mix(in srgb, var(--page-accent) 13%, white);
color: var(--foreground);
}
.app-main {
flex: 1;
overflow: auto;
padding: 1rem;
}
.app-main > div {
margin: 0 auto;
width: min(1160px, 100%);
}
.app-main h2 {
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.9rem, 3.3vw, 2.7rem);
line-height: 1.02;
letter-spacing: 0.01em;
}
.app-main h3 {
font-family: 'Cormorant Infant', 'Times New Roman', serif;
}
.editorial-page {
width: min(1060px, 100%);
margin: 0 auto;
}
.editorial-backlink {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.875rem;
}
.editorial-backlink:hover {
color: var(--foreground);
}
.editorial-toolbar {
margin-top: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.editorial-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.editorial-alert {
border-radius: 0.7rem;
border: 1px solid hsl(34 25% 75% / 0.8);
background: hsl(42 36% 93%);
padding: 0.72rem 0.85rem;
font-size: 0.9rem;
}
.editorial-alert--error {
border-color: hsl(3 53% 71%);
background: hsl(4 72% 93%);
color: hsl(3 62% 34%);
}
.editorial-alert--success {
border-color: hsl(132 28% 72%);
background: hsl(127 36% 92%);
color: hsl(136 48% 26%);
}
.products-table-shell {
border: 1px solid hsl(35 24% 74% / 0.85);
border-radius: 0.9rem;
overflow: hidden;
}
.products-category-row {
background: color-mix(in srgb, var(--page-accent) 10%, white);
}
.products-mobile-card {
display: block;
border: 1px solid hsl(35 21% 76% / 0.85);
border-radius: 0.8rem;
padding: 0.95rem;
}
.products-section-title {
border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border));
padding-bottom: 0.3rem;
padding-top: 0.5rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.13em;
color: var(--muted-foreground);
text-transform: uppercase;
}
.products-sticky-actions {
border-color: color-mix(in srgb, var(--page-accent) 25%, var(--border));
}
.products-meta-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
color: var(--muted-foreground);
font-size: 0.9rem;
}
.products-tabs [data-slot='tabs-list'],
.editorial-tabs [data-slot='tabs-list'] {
border: 1px solid hsl(35 22% 75% / 0.75);
background: hsl(40 28% 93%);
}
.routine-ledger-row {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid hsl(35 21% 76% / 0.82);
border-radius: 0.75rem;
padding: 0.75rem 0.9rem;
text-decoration: none;
color: inherit;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.routine-ledger-row:hover {
transform: translateX(2px);
border-color: color-mix(in srgb, var(--page-accent) 42%, var(--border));
background: var(--page-accent-soft);
}
.health-entry-row {
border: 1px solid hsl(35 21% 76% / 0.82);
border-radius: 0.75rem;
padding: 0.8rem 0.9rem;
background: linear-gradient(165deg, hsl(44 31% 96%), hsl(42 30% 94%));
}
.health-kind-pill,
.health-flag-pill {
display: inline-flex;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.26rem 0.62rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.health-kind-pill--prescription,
.health-flag-pill--abnormal,
.health-flag-pill--high {
border-color: hsl(4 54% 70%);
background: hsl(5 58% 91%);
color: hsl(5 58% 31%);
}
.health-kind-pill--otc,
.health-flag-pill--negative {
border-color: hsl(206 40% 69%);
background: hsl(205 45% 90%);
color: hsl(208 53% 29%);
}
.health-kind-pill--supplement,
.health-kind-pill--herbal,
.health-flag-pill--normal {
border-color: hsl(136 27% 67%);
background: hsl(132 31% 90%);
color: hsl(136 49% 26%);
}
.health-kind-pill--other {
border-color: hsl(35 20% 70%);
background: hsl(40 22% 89%);
color: hsl(28 24% 29%);
}
.health-flag-pill--positive,
.health-flag-pill--low {
border-color: hsl(33 53% 67%);
background: hsl(35 55% 90%);
color: hsl(28 55% 30%);
}
[data-slot='card'] {
border-color: hsl(35 22% 75% / 0.8);
background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%));
}
[data-slot='input'] {
border-color: hsl(36 21% 74%);
background: hsl(42 28% 96%);
}
[data-slot='input']:focus-visible {
border-color: color-mix(in srgb, var(--page-accent) 58%, white);
}
[data-slot='button']:focus-visible,
[data-slot='badge']:focus-visible {
outline-color: var(--page-accent);
}
@media (min-width: 768px) {
.app-shell {
flex-direction: row;
}
.app-main {
padding: 2rem;
}
}
.editorial-dashboard {
position: relative;
margin: 0 auto;
width: min(1100px, 100%);
color: var(--editorial-ink);
}
.editorial-atmosphere {
pointer-events: none;
position: absolute;
inset: -2.5rem -1rem auto;
z-index: 0;
height: 14rem;
border-radius: 2rem;
background:
radial-gradient(
circle at 18% 34%,
color-mix(in srgb, var(--page-accent) 24%, white) 0%,
transparent 47%
),
radial-gradient(circle at 74% 16%, hsl(198 63% 85% / 0.52), transparent 39%),
linear-gradient(130deg, hsl(45 48% 94%), hsl(34 38% 91%));
filter: saturate(110%);
}
.editorial-hero,
.editorial-panel {
position: relative;
z-index: 1;
overflow: hidden;
border: 1px solid hsl(36 26% 74% / 0.8);
background: linear-gradient(160deg, hsl(44 40% 95%), var(--editorial-paper));
box-shadow:
0 24px 48px -34px hsl(219 32% 14% / 0.44),
inset 0 1px 0 hsl(0 0% 100% / 0.75);
}
.editorial-hero {
margin-bottom: 1.1rem;
border-radius: 1.5rem;
padding: clamp(1.2rem, 2.6vw, 2rem);
}
.editorial-kicker {
margin-bottom: 0.4rem;
color: var(--editorial-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.editorial-title {
margin: 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(2.2rem, 5vw, 3.6rem);
font-weight: 600;
line-height: 0.95;
letter-spacing: 0.01em;
}
.editorial-subtitle {
margin-top: 0.66rem;
max-width: 48ch;
color: var(--editorial-muted);
font-size: 0.98rem;
}
.hero-strip {
margin-top: 1.3rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
border-top: 1px dashed color-mix(in srgb, var(--page-accent) 30%, var(--editorial-line));
padding-top: 0.9rem;
}
.hero-strip-label {
margin: 0;
color: var(--editorial-muted);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.hero-strip-value {
margin: 0.22rem 0 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: 1.4rem;
font-weight: 600;
}
.editorial-grid {
position: relative;
z-index: 1;
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr);
}
.editorial-panel {
border-radius: 1.2rem;
padding: 1rem;
}
.panel-header {
margin-bottom: 0.9rem;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid hsl(36 20% 73% / 0.72);
padding-bottom: 0.6rem;
}
.panel-header h3 {
margin: 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
font-weight: 600;
}
.panel-index {
margin: 0;
color: var(--editorial-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.16em;
}
.panel-action-row {
margin-bottom: 0.7rem;
display: flex;
}
.snapshot-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.snapshot-date {
color: var(--editorial-muted);
font-size: 0.9rem;
font-weight: 600;
}
.state-pill,
.routine-pill {
display: inline-flex;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.28rem 0.68rem;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.state-pill--excellent {
border-color: hsl(145 34% 65%);
background: hsl(146 42% 90%);
color: hsl(144 48% 26%);
}
.state-pill--good {
border-color: hsl(191 44% 68%);
background: hsl(190 56% 90%);
color: hsl(193 60% 24%);
}
.state-pill--fair {
border-color: hsl(40 68% 67%);
background: hsl(44 76% 90%);
color: hsl(35 63% 30%);
}
.state-pill--poor {
border-color: hsl(4 64% 67%);
background: hsl(6 72% 89%);
color: hsl(8 64% 33%);
}
.concern-cloud {
margin-top: 0.92rem;
display: flex;
flex-wrap: wrap;
gap: 0.44rem;
}
.concern-chip {
border: 1px solid hsl(36 24% 71% / 0.88);
border-radius: 0.42rem;
background: hsl(42 36% 92%);
padding: 0.36rem 0.52rem;
color: hsl(220 20% 22%);
font-size: 0.81rem;
font-weight: 600;
}
.snapshot-notes {
margin-top: 0.9rem;
border-left: 2px solid hsl(37 34% 66% / 0.8);
padding-left: 0.8rem;
color: hsl(220 13% 34%);
font-size: 0.94rem;
line-height: 1.45;
}
.routine-list {
margin: 0;
padding: 0;
list-style: none;
}
.routine-summary-strip {
margin-bottom: 0.7rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.routine-summary-chip {
border: 1px solid hsl(35 24% 71% / 0.85);
border-radius: 999px;
padding: 0.22rem 0.62rem;
color: var(--editorial-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.panel-action-link,
.routine-summary-link {
border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line));
border-radius: 999px;
padding: 0.24rem 0.64rem;
color: var(--page-accent);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-decoration: none;
text-transform: uppercase;
}
.routine-summary-link {
margin-left: auto;
}
.panel-action-link:hover,
.routine-summary-link:hover {
background: var(--page-accent-soft);
}
.routine-item + .routine-item {
border-top: 1px dashed hsl(36 26% 72% / 0.7);
}
.routine-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.78rem 0;
text-decoration: none;
color: inherit;
transition: transform 140ms ease, color 160ms ease;
}
.routine-main {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.routine-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.routine-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--editorial-muted);
font-size: 0.8rem;
}
.routine-note-inline {
overflow: hidden;
max-width: 38ch;
white-space: nowrap;
text-overflow: ellipsis;
}
.routine-link:hover {
transform: translateX(4px);
color: var(--page-accent);
}
.routine-link:focus-visible {
outline: 2px solid var(--page-accent);
outline-offset: 3px;
border-radius: 0.4rem;
}
.routine-date {
font-size: 0.93rem;
font-weight: 600;
}
.routine-pill--am {
border-color: hsl(188 43% 66%);
background: hsl(188 52% 89%);
color: hsl(194 56% 24%);
}
.routine-pill--pm {
border-color: hsl(21 58% 67%);
background: hsl(23 68% 90%);
color: hsl(14 56% 31%);
}
.empty-copy {
margin: 0;
color: var(--editorial-muted);
font-size: 0.95rem;
}
.empty-actions {
margin-top: 0.75rem;
display: flex;
}
.reveal-1,
.reveal-2,
.reveal-3 {
opacity: 0;
transform: translateY(16px);
animation: editorial-rise 620ms cubic-bezier(0.2, 0.85, 0.24, 1) forwards;
}
.reveal-2 {
animation-delay: 90ms;
}
.reveal-3 {
animation-delay: 160ms;
}
@keyframes editorial-rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1024px) {
.editorial-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.editorial-title {
font-size: 2.05rem;
}
.panel-header {
align-items: center;
}
.panel-header h3 {
font-size: 1.4rem;
}
.state-pill,
.routine-pill {
letter-spacing: 0.08em;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-1,
.reveal-2,
.reveal-3 {
opacity: 1;
transform: none;
animation: none;
}
.routine-link {
transition: none;
}
}

View file

@ -3,6 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Infant:wght@500;600;700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -5,26 +5,26 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { parseProductText, type ProductParseResponse } from '$lib/api';
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
import type { ProductParseResponse } from '$lib/api';
import { m } from '$lib/paraglide/messages.js';
import { Sparkles, X } from 'lucide-svelte';
let {
product,
dirty = $bindable(false),
saveVersion = 0,
showAiTrigger = true,
onDirtyChange,
computedPriceLabel,
computedPricePerUseLabel,
computedPriceTierLabel
}: {
product?: Product;
dirty?: boolean;
saveVersion?: number;
showAiTrigger?: boolean;
onDirtyChange?: (dirty: boolean) => void;
computedPriceLabel?: string;
computedPricePerUseLabel?: string;
computedPriceTierLabel?: string;
@ -140,10 +140,6 @@
{ value: 'false', label: m.common_no() }
]);
function tristateLabel(val: string): string {
return val === '' ? m.common_unknown() : val === 'true' ? m.common_yes() : m.common_no();
}
const effectFields = $derived([
{ key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() },
{ key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() },
@ -201,6 +197,7 @@
aiLoading = true;
aiError = '';
try {
const { parseProductText } = await import('$lib/api');
const r = await parseProductText(aiText);
applyAiResult(r);
aiModalOpen = false;
@ -379,11 +376,9 @@
)
);
const textareaClass =
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
const textareaClass = `${baseTextareaClass} focus-visible:ring-offset-2`;
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';
const selectClass = baseSelectClass;
export function openAiModal() {
aiError = '';
@ -466,11 +461,11 @@
baselineFingerprint = currentFingerprint;
baselineProductId = currentProductId;
baselineSaveVersion = saveVersion;
dirty = false;
onDirtyChange?.(false);
return;
}
dirty = currentFingerprint !== baselineFingerprint;
onDirtyChange?.(currentFingerprint !== baselineFingerprint);
});
</script>
@ -496,153 +491,51 @@
{/if}
{#if aiModalOpen}
<button
type="button"
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>
{#await import('$lib/components/ProductFormAiModal.svelte') then mod}
{@const AiModal = mod.default}
<AiModal
open={aiModalOpen}
bind:aiText
{aiLoading}
aiError={aiError}
{textareaClass}
onClose={closeAiModal}
onSubmit={parseWithAi}
/>
{/await}
{/if}
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
<Card class={editSection === 'basic' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
</div>
<div class="space-y-2">
<Label for="brand">{m["productForm_brand"]()}</Label>
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
</div>
<div class="space-y-2">
<Label for="url">{m["productForm_url"]()}</Label>
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
</div>
<div class="space-y-2">
<Label for="barcode">{m["productForm_barcode"]()}</Label>
<Input id="barcode" name="barcode" placeholder={m["productForm_barcodePlaceholder"]()} bind:value={barcode} />
</div>
</div>
</CardContent>
</Card>
{#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod}
{@const BasicSection = mod.default}
<BasicSection
visible={editSection === 'basic'}
bind:name
bind:brand
bind:lineName
bind:url
bind:sku
bind:barcode
/>
{/await}
<!-- ── Classification ────────────────────────────────────────────────────── -->
<Card class={editSection === 'basic' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-2 space-y-2">
<Label>{m["productForm_category"]()}</Label>
<input type="hidden" name="category" value={category} />
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
<SelectTrigger>{category ? categoryLabels[category] : m["productForm_selectCategory"]()}</SelectTrigger>
<SelectContent>
{#each categories as cat}
<SelectItem value={cat}>{categoryLabels[cat]}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_time"]()}</Label>
<input type="hidden" name="recommended_time" value={recommendedTime} />
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
<SelectTrigger>
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
</SelectTrigger>
<SelectContent>
<SelectItem value="am">{m.common_am()}</SelectItem>
<SelectItem value="pm">{m.common_pm()}</SelectItem>
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_leaveOn"]()}</Label>
<input type="hidden" name="leave_on" value={leaveOn} />
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
<SelectTrigger>{leaveOn === 'true' ? m["productForm_leaveOnYes"]() : m["productForm_leaveOnNo"]()}</SelectTrigger>
<SelectContent>
<SelectItem value="true">{m["productForm_leaveOnYes"]()}</SelectItem>
<SelectItem value="false">{m["productForm_leaveOnNo"]()}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_texture"]()}</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture ? textureLabels[texture] : m["productForm_selectTexture"]()}</SelectTrigger>
<SelectContent>
{#each textures as t}
<SelectItem value={t}>{textureLabels[t]}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_absorptionSpeed"]()}</Label>
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
<SelectTrigger>{absorptionSpeed ? absorptionLabels[absorptionSpeed] : m["productForm_selectSpeed"]()}</SelectTrigger>
<SelectContent>
{#each absorptionSpeeds as s}
<SelectItem value={s}>{absorptionLabels[s]}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod}
{@const ClassificationSection = mod.default}
<ClassificationSection
visible={editSection === 'basic'}
{selectClass}
{categories}
{textures}
{absorptionSpeeds}
{categoryLabels}
{textureLabels}
{absorptionLabels}
bind:category
bind:recommendedTime
bind:leaveOn
bind:texture
bind:absorptionSpeed
/>
{/await}
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
@ -651,7 +544,7 @@
<div class="space-y-2">
<Label>{m["productForm_recommendedFor"]()}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinTypes as st}
{#each skinTypes as st (st)}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
@ -675,7 +568,7 @@
<div class="space-y-2">
<Label>{m["productForm_targetConcerns"]()}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinConcerns as sc}
{#each skinConcerns as sc (sc)}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
@ -734,7 +627,7 @@
onclick={() => (activesPanelOpen = !activesPanelOpen)}
>
<span class="text-sm font-medium">{m["productForm_activeIngredients"]()}</span>
<span class="text-xs text-muted-foreground">{activesPanelOpen ? 'Ukryj' : 'Pokaż'}</span>
<span class="text-xs text-muted-foreground">{activesPanelOpen ? '' : '+'}</span>
</button>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div>
@ -742,7 +635,7 @@
<input type="hidden" name="actives_json" value={activesJson} />
{#if activesPanelOpen}
{#each actives as active, i}
{#each actives as active, i (i)}
<div class="rounded-md border border-border p-3 space-y-3">
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
@ -795,7 +688,7 @@
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
{#each ingFunctions as fn}
{#each ingFunctions as fn (fn)}
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input
type="checkbox"
@ -820,319 +713,59 @@
</Card>
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each effectFields as field}
{@const key = field.key as keyof typeof effectValues}
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
<span class="text-xs text-muted-foreground">{field.label}</span>
<input
type="range"
name="effect_{field.key}"
min="0"
max="5"
step="1"
bind:value={effectValues[key]}
class="accent-primary"
/>
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
</div>
{/each}
</div>
</CardContent>
</Card>
{#await import('$lib/components/product-form/ProductFormAssessmentSection.svelte') then mod}
{@const AssessmentSection = mod.default}
<AssessmentSection
visible={editSection === 'assessment'}
{selectClass}
{effectFields}
bind:effectValues
{tristate}
bind:ctxAfterShaving
bind:ctxAfterAcids
bind:ctxAfterRetinoids
bind:ctxCompromisedBarrier
bind:ctxLowUvOnly
bind:fragranceFree
bind:essentialOilsFree
bind:alcoholDenatFree
bind:pregnancySafe
/>
{/await}
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}>
<SelectTrigger>{tristateLabel(ctxAfterShaving)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
{#await import('$lib/components/product-form/ProductFormDetailsSection.svelte') then mod}
{@const DetailsSection = mod.default}
<DetailsSection
visible={editSection === 'details'}
{textareaClass}
bind:priceAmount
bind:priceCurrency
bind:sizeMl
bind:fullWeightG
bind:emptyWeightG
bind:paoMonths
bind:phMin
bind:phMax
bind:usageNotes
bind:minIntervalHours
bind:maxFrequencyPerWeek
bind:needleLengthMm
bind:isMedication
bind:isTool
{computedPriceLabel}
{computedPricePerUseLabel}
{computedPriceTierLabel}
/>
{/await}
<div class="space-y-2">
<Label>{m["productForm_ctxAfterAcids"]()}</Label>
<input type="hidden" name="ctx_safe_after_acids" value={ctxAfterAcids} />
<Select type="single" value={ctxAfterAcids} onValueChange={(v) => (ctxAfterAcids = v)}>
<SelectTrigger>{tristateLabel(ctxAfterAcids)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxAfterRetinoids"]()}</Label>
<input type="hidden" name="ctx_safe_after_retinoids" value={ctxAfterRetinoids} />
<Select
type="single"
value={ctxAfterRetinoids}
onValueChange={(v) => (ctxAfterRetinoids = v)}
>
<SelectTrigger>{tristateLabel(ctxAfterRetinoids)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxCompromisedBarrier"]()}</Label>
<input type="hidden" name="ctx_safe_with_compromised_barrier" value={ctxCompromisedBarrier} />
<Select
type="single"
value={ctxCompromisedBarrier}
onValueChange={(v) => (ctxCompromisedBarrier = v)}
>
<SelectTrigger>{tristateLabel(ctxCompromisedBarrier)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_ctxLowUvOnly"]()}</Label>
<input type="hidden" name="ctx_low_uv_only" value={ctxLowUvOnly} />
<Select type="single" value={ctxLowUvOnly} onValueChange={(v) => (ctxLowUvOnly = v)}>
<SelectTrigger>{tristateLabel(ctxLowUvOnly)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<!-- ── Product details ────────────────────────────────────────────────────── -->
<Card class={editSection === 'details' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<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>
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div class="space-y-2">
<div>
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
<p class="font-medium">{computedPriceTierLabel ?? 'n/a'}</p>
</div>
</div>
</div>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<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={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
</div>
<div class="space-y-2">
<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={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
</div>
</div>
<div class="space-y-2">
<Label for="usage_notes">{m["productForm_usageNotes"]()}</Label>
<textarea
id="usage_notes"
name="usage_notes"
rows="2"
placeholder={m["productForm_usageNotesPlaceholder"]()}
class={textareaClass}
bind:value={usageNotes}
></textarea>
</div>
</CardContent>
</Card>
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label>{m["productForm_fragranceFree"]()}</Label>
<input type="hidden" name="fragrance_free" value={fragranceFree} />
<Select type="single" value={fragranceFree} onValueChange={(v) => (fragranceFree = v)}>
<SelectTrigger>{tristateLabel(fragranceFree)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_essentialOilsFree"]()}</Label>
<input type="hidden" name="essential_oils_free" value={essentialOilsFree} />
<Select
type="single"
value={essentialOilsFree}
onValueChange={(v) => (essentialOilsFree = v)}
>
<SelectTrigger>{tristateLabel(essentialOilsFree)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_alcoholDenatFree"]()}</Label>
<input type="hidden" name="alcohol_denat_free" value={alcoholDenatFree} />
<Select
type="single"
value={alcoholDenatFree}
onValueChange={(v) => (alcoholDenatFree = v)}
>
<SelectTrigger>{tristateLabel(alcoholDenatFree)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{m["productForm_pregnancySafe"]()}</Label>
<input type="hidden" name="pregnancy_safe" value={pregnancySafe} />
<Select type="single" value={pregnancySafe} onValueChange={(v) => (pregnancySafe = v)}>
<SelectTrigger>{tristateLabel(pregnancySafe)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
<Card class={editSection === 'details' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
</div>
<div class="space-y-2">
<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={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
</div>
</div>
<div class="flex gap-6">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_medication" value={String(isMedication)} />
<input
type="checkbox"
checked={isMedication}
onchange={() => (isMedication = !isMedication)}
class="rounded border-input"
/>
{m["productForm_isMedication"]()}
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_tool" value={String(isTool)} />
<input
type="checkbox"
checked={isTool}
onchange={() => (isTool = !isTool)}
class="rounded border-input"
/>
{m["productForm_isTool"]()}
</label>
</div>
<div class="space-y-2">
<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={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
</div>
</CardContent>
</Card>
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
<Card class={editSection === 'notes' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>{m["productForm_repurchaseIntent"]()}</Label>
<input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} />
<Select
type="single"
value={personalRepurchaseIntent}
onValueChange={(v) => (personalRepurchaseIntent = v)}
>
<SelectTrigger>{tristateLabel(personalRepurchaseIntent)}</SelectTrigger>
<SelectContent>
{#each tristate as opt}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
<textarea
id="personal_tolerance_notes"
name="personal_tolerance_notes"
rows="2"
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
class={textareaClass}
bind:value={personalToleranceNotes}
></textarea>
</div>
</CardContent>
</Card>
{#await import('$lib/components/product-form/ProductFormNotesSection.svelte') then mod}
{@const NotesSection = mod.default}
<NotesSection
visible={editSection === 'notes'}
{selectClass}
{textareaClass}
{tristate}
bind:personalRepurchaseIntent
bind:personalToleranceNotes
/>
{/await}

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Sparkles, X } from 'lucide-svelte';
let {
open = false,
aiText = $bindable(''),
aiLoading = false,
aiError = '',
textareaClass,
onClose,
onSubmit
}: {
open?: boolean;
aiText?: string;
aiLoading?: boolean;
aiError?: string;
textareaClass: string;
onClose: () => void;
onSubmit: () => void;
} = $props();
</script>
{#if open}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
onclick={onClose}
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={onClose} 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={onClose} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={onSubmit} disabled={aiLoading || !aiText.trim()}>
{#if aiLoading}
{m["productForm_parsing"]()}
{:else}
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
{/if}
</Button>
</div>
</CardContent>
</Card>
</div>
{/if}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let {
title,
titleClass = 'text-base',
className,
contentClassName,
children
}: {
title: string;
titleClass?: string;
className?: string;
contentClassName?: string;
children?: import('svelte').Snippet;
} = $props();
</script>
<Card class={className}>
<CardHeader><CardTitle class={titleClass}>{title}</CardTitle></CardHeader>
<CardContent class={contentClassName}>
{@render children?.()}
</CardContent>
</Card>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { baseSelectClass } from '$lib/components/forms/form-classes';
type SelectOption = { value: string; label: string };
type SelectGroup = { label: string; options: SelectOption[] };
let {
id,
name,
label,
groups,
value = $bindable(''),
placeholder = '',
required = false,
className = '',
onChange
}: {
id: string;
name?: string;
label: string;
groups: SelectGroup[];
value?: string;
placeholder?: string;
required?: boolean;
className?: string;
onChange?: (value: string) => void;
} = $props();
const selectClass = $derived(className ? `${baseSelectClass} ${className}` : baseSelectClass);
</script>
<div class="space-y-1">
<Label for={id}>{label}</Label>
<select
{id}
{name}
class={selectClass}
bind:value
{required}
onchange={(e) => onChange?.(e.currentTarget.value)}
>
{#if placeholder}
<option value="">{placeholder}</option>
{/if}
{#each groups as group (group.label)}
<optgroup label={group.label}>
{#each group.options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</optgroup>
{/each}
</select>
</div>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
let {
id,
name,
label,
hint,
checked = $bindable(false),
disabled = false,
className = ''
}: {
id: string;
name: string;
label: string;
hint?: string;
checked?: boolean;
disabled?: boolean;
className?: string;
} = $props();
const wrapperClass = $derived(
className
? `flex items-start gap-3 rounded-md border border-border px-3 py-2 ${className}`
: 'flex items-start gap-3 rounded-md border border-border px-3 py-2'
);
</script>
<div class={wrapperClass}>
<input
{id}
{name}
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
bind:checked
{disabled}
/>
<div class="space-y-0.5">
<Label for={id} class="font-medium">{label}</Label>
{#if hint}
<p class="text-xs text-muted-foreground">{hint}</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
let {
id,
name,
label,
value = $bindable(''),
type = 'text',
placeholder,
required = false,
min,
max,
step,
className = 'space-y-1'
}: {
id: string;
name: string;
label: string;
value?: string;
type?: 'text' | 'number' | 'date' | 'url';
placeholder?: string;
required?: boolean;
min?: string;
max?: string;
step?: string;
className?: string;
} = $props();
</script>
<div class={className}>
<Label for={id}>{label}</Label>
<Input
{id}
{name}
{type}
{required}
{placeholder}
{min}
{max}
{step}
bind:value
/>
</div>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { baseSelectClass } from '$lib/components/forms/form-classes';
type SelectOption = { value: string; label: string };
let {
id,
name,
label,
options,
value = $bindable(''),
placeholder = '',
required = false,
className = '',
onChange
}: {
id: string;
name?: string;
label: string;
options: SelectOption[];
value?: string;
placeholder?: string;
required?: boolean;
className?: string;
onChange?: (value: string) => void;
} = $props();
const selectClass = $derived(className ? `${baseSelectClass} ${className}` : baseSelectClass);
</script>
<div class="space-y-1">
<Label for={id}>{label}</Label>
<select
{id}
{name}
class={selectClass}
bind:value
{required}
onchange={(e) => onChange?.(e.currentTarget.value)}
>
{#if placeholder}
<option value="">{placeholder}</option>
{/if}
{#each options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>

View file

@ -0,0 +1,5 @@
export const baseSelectClass =
'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 const baseTextareaClass =
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
type EffectField = { key: string; label: string };
type TriOption = { value: string; label: string };
let {
visible = false,
selectClass,
effectFields,
effectValues = $bindable<Record<string, number>>({}),
tristate,
ctxAfterShaving = $bindable(''),
ctxAfterAcids = $bindable(''),
ctxAfterRetinoids = $bindable(''),
ctxCompromisedBarrier = $bindable(''),
ctxLowUvOnly = $bindable(''),
fragranceFree = $bindable(''),
essentialOilsFree = $bindable(''),
alcoholDenatFree = $bindable(''),
pregnancySafe = $bindable('')
}: {
visible?: boolean;
selectClass: string;
effectFields: EffectField[];
effectValues?: Record<string, number>;
tristate: TriOption[];
ctxAfterShaving?: string;
ctxAfterAcids?: string;
ctxAfterRetinoids?: string;
ctxCompromisedBarrier?: string;
ctxLowUvOnly?: string;
fragranceFree?: string;
essentialOilsFree?: string;
alcoholDenatFree?: string;
pregnancySafe?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each effectFields as field (field.key)}
{@const key = field.key}
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
<span class="text-xs text-muted-foreground">{field.label}</span>
<input
type="range"
name="effect_{field.key}"
min="0"
max="5"
step="1"
bind:value={effectValues[key]}
class="accent-primary"
/>
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ctx_shaving_select">{m["productForm_ctxAfterShaving"]()}</Label>
<select id="ctx_shaving_select" name="ctx_safe_after_shaving" class={selectClass} bind:value={ctxAfterShaving}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_acids_select">{m["productForm_ctxAfterAcids"]()}</Label>
<select id="ctx_acids_select" name="ctx_safe_after_acids" class={selectClass} bind:value={ctxAfterAcids}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_retinoids_select">{m["productForm_ctxAfterRetinoids"]()}</Label>
<select id="ctx_retinoids_select" name="ctx_safe_after_retinoids" class={selectClass} bind:value={ctxAfterRetinoids}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_barrier_select">{m["productForm_ctxCompromisedBarrier"]()}</Label>
<select id="ctx_barrier_select" name="ctx_safe_with_compromised_barrier" class={selectClass} bind:value={ctxCompromisedBarrier}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="ctx_uv_select">{m["productForm_ctxLowUvOnly"]()}</Label>
<select id="ctx_uv_select" name="ctx_low_uv_only" class={selectClass} bind:value={ctxLowUvOnly}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</CardContent>
</Card>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fragrance_free_select">{m["productForm_fragranceFree"]()}</Label>
<select id="fragrance_free_select" name="fragrance_free" class={selectClass} bind:value={fragranceFree}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="essential_oils_free_select">{m["productForm_essentialOilsFree"]()}</Label>
<select id="essential_oils_free_select" name="essential_oils_free" class={selectClass} bind:value={essentialOilsFree}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="alcohol_denat_free_select">{m["productForm_alcoholDenatFree"]()}</Label>
<select id="alcohol_denat_free_select" name="alcohol_denat_free" class={selectClass} bind:value={alcoholDenatFree}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="pregnancy_safe_select">{m["productForm_pregnancySafe"]()}</Label>
<select id="pregnancy_safe_select" name="pregnancy_safe" class={selectClass} bind:value={pregnancySafe}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
let {
visible = true,
name = $bindable(''),
brand = $bindable(''),
lineName = $bindable(''),
url = $bindable(''),
sku = $bindable(''),
barcode = $bindable('')
}: {
visible?: boolean;
name?: string;
brand?: string;
lineName?: string;
url?: string;
sku?: string;
barcode?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
</div>
<div class="space-y-2">
<Label for="brand">{m["productForm_brand"]()}</Label>
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
</div>
<div class="space-y-2">
<Label for="url">{m["productForm_url"]()}</Label>
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
</div>
<div class="space-y-2">
<Label for="barcode">{m["productForm_barcode"]()}</Label>
<Input id="barcode" name="barcode" placeholder={m["productForm_barcodePlaceholder"]()} bind:value={barcode} />
</div>
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
let {
visible = true,
selectClass,
categories,
textures,
absorptionSpeeds,
categoryLabels,
textureLabels,
absorptionLabels,
category = $bindable(''),
recommendedTime = $bindable(''),
leaveOn = $bindable('true'),
texture = $bindable(''),
absorptionSpeed = $bindable('')
}: {
visible?: boolean;
selectClass: string;
categories: string[];
textures: string[];
absorptionSpeeds: string[];
categoryLabels: Record<string, string>;
textureLabels: Record<string, string>;
absorptionLabels: Record<string, string>;
category?: string;
recommendedTime?: string;
leaveOn?: string;
texture?: string;
absorptionSpeed?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-2 space-y-2">
<Label for="category_select">{m["productForm_category"]()}</Label>
<select id="category_select" name="category" class={selectClass} bind:value={category}>
<option value="">{m["productForm_selectCategory"]()}</option>
{#each categories as cat (cat)}
<option value={cat}>{categoryLabels[cat]}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="recommended_time_select">{m["productForm_time"]()}</Label>
<select id="recommended_time_select" name="recommended_time" class={selectClass} bind:value={recommendedTime}>
<option value="">{m["productForm_timeOptions"]()}</option>
<option value="am">{m.common_am()}</option>
<option value="pm">{m.common_pm()}</option>
<option value="both">{m["productForm_timeBoth"]()}</option>
</select>
</div>
<div class="space-y-2">
<Label for="leave_on_select">{m["productForm_leaveOn"]()}</Label>
<select id="leave_on_select" name="leave_on" class={selectClass} bind:value={leaveOn}>
<option value="true">{m["productForm_leaveOnYes"]()}</option>
<option value="false">{m["productForm_leaveOnNo"]()}</option>
</select>
</div>
<div class="space-y-2">
<Label for="texture_select">{m["productForm_texture"]()}</Label>
<select id="texture_select" name="texture" class={selectClass} bind:value={texture}>
<option value="">{m["productForm_selectTexture"]()}</option>
{#each textures as t (t)}
<option value={t}>{textureLabels[t]}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="absorption_speed_select">{m["productForm_absorptionSpeed"]()}</Label>
<select id="absorption_speed_select" name="absorption_speed" class={selectClass} bind:value={absorptionSpeed}>
<option value="">{m["productForm_selectSpeed"]()}</option>
{#each absorptionSpeeds as s (s)}
<option value={s}>{absorptionLabels[s]}</option>
{/each}
</select>
</div>
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
let {
visible = false,
textareaClass,
priceAmount = $bindable(''),
priceCurrency = $bindable('PLN'),
sizeMl = $bindable(''),
fullWeightG = $bindable(''),
emptyWeightG = $bindable(''),
paoMonths = $bindable(''),
phMin = $bindable(''),
phMax = $bindable(''),
usageNotes = $bindable(''),
minIntervalHours = $bindable(''),
maxFrequencyPerWeek = $bindable(''),
needleLengthMm = $bindable(''),
isMedication = $bindable(false),
isTool = $bindable(false),
computedPriceLabel,
computedPricePerUseLabel,
computedPriceTierLabel
}: {
visible?: boolean;
textareaClass: string;
priceAmount?: string;
priceCurrency?: string;
sizeMl?: string;
fullWeightG?: string;
emptyWeightG?: string;
paoMonths?: string;
phMin?: string;
phMax?: string;
usageNotes?: string;
minIntervalHours?: string;
maxFrequencyPerWeek?: string;
needleLengthMm?: string;
isMedication?: boolean;
isTool?: boolean;
computedPriceLabel?: string;
computedPricePerUseLabel?: string;
computedPriceTierLabel?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<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>
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div class="space-y-2">
<div>
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
<p class="font-medium">{computedPriceTierLabel ?? m.common_unknown()}</p>
</div>
</div>
</div>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<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={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
</div>
<div class="space-y-2">
<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={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
</div>
</div>
<div class="space-y-2">
<Label for="usage_notes">{m["productForm_usageNotes"]()}</Label>
<textarea
id="usage_notes"
name="usage_notes"
rows="2"
placeholder={m["productForm_usageNotesPlaceholder"]()}
class={textareaClass}
bind:value={usageNotes}
></textarea>
</div>
</CardContent>
</Card>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
</div>
<div class="space-y-2">
<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={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
</div>
</div>
<div class="flex gap-6">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_medication" value={String(isMedication)} />
<input
type="checkbox"
checked={isMedication}
onchange={() => (isMedication = !isMedication)}
class="rounded border-input"
/>
{m["productForm_isMedication"]()}
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_tool" value={String(isTool)} />
<input
type="checkbox"
checked={isTool}
onchange={() => (isTool = !isTool)}
class="rounded border-input"
/>
{m["productForm_isTool"]()}
</label>
</div>
<div class="space-y-2">
<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={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
type TriOption = { value: string; label: string };
let {
visible = false,
selectClass,
textareaClass,
tristate,
personalRepurchaseIntent = $bindable(''),
personalToleranceNotes = $bindable('')
}: {
visible?: boolean;
selectClass: string;
textareaClass: string;
tristate: TriOption[];
personalRepurchaseIntent?: string;
personalToleranceNotes?: string;
} = $props();
</script>
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="repurchase_intent_select">{m["productForm_repurchaseIntent"]()}</Label>
<select id="repurchase_intent_select" name="personal_repurchase_intent" class={selectClass} bind:value={personalRepurchaseIntent}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
<textarea
id="personal_tolerance_notes"
name="personal_tolerance_notes"
rows="2"
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
class={textareaClass}
bind:value={personalToleranceNotes}
></textarea>
</div>
</CardContent>
</Card>

View file

@ -2,16 +2,16 @@
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-semibold whitespace-nowrap tracking-[0.08em] uppercase transition-[color,box-shadow,border-color,background-color] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
"bg-[var(--page-accent)] text-white [a&]:hover:brightness-95 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
outline: "border-border bg-transparent text-foreground [a&]:hover:border-[color:var(--page-accent)] [a&]:hover:bg-[var(--page-accent-soft)]",
},
},
defaultVariants: {

View file

@ -4,17 +4,17 @@
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md border text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
default: "border-transparent bg-[var(--page-accent)] text-white shadow-sm hover:brightness-95",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
"border-transparent bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-white shadow-sm",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"border-border bg-card text-card-foreground shadow-sm hover:border-[color:var(--page-accent)] hover:bg-[var(--page-accent-soft)]",
secondary: "border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-[var(--page-accent-soft)]",
link: "border-transparent px-0 text-[var(--page-accent)] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",

View file

@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-[0_18px_36px_-32px_hsl(210_24%_15%_/_0.55)]",
className
)}
{...restProps}

View file

@ -25,7 +25,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
@ -40,7 +40,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className

View file

@ -19,6 +19,7 @@
let { children } = $props();
let mobileMenuOpen = $state(false);
const domainClass = $derived(getDomainClass(page.url.pathname));
const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
@ -40,18 +41,27 @@
);
return !moreSpecific;
}
function getDomainClass(pathname: string): string {
if (pathname.startsWith('/products')) return 'domain-products';
if (pathname.startsWith('/routines')) return 'domain-routines';
if (pathname.startsWith('/skin')) return 'domain-skin';
if (pathname.startsWith('/health/lab-results')) return 'domain-health-labs';
if (pathname.startsWith('/health/medications')) return 'domain-health-meds';
return 'domain-dashboard';
}
</script>
<div class="flex min-h-screen flex-col bg-background md:flex-row">
<div class="app-shell {domainClass}">
<!-- Mobile header -->
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden">
<header class="app-mobile-header md:hidden">
<div>
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
<span class="app-mobile-title">{m["nav_appName"]()}</span>
</div>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
class="app-icon-button"
aria-label={m.common_toggleMenu()}
>
{#if mobileMenuOpen}
@ -73,14 +83,14 @@
></button>
<!-- Drawer (same z-50 but later in DOM, on top) -->
<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 app-sidebar"
>
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
{#each navItems as item (item.href)}
<li>
<a
href={item.href}
@ -103,13 +113,13 @@
{/if}
<!-- Desktop Sidebar -->
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
<nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
{#each navItems as item (item.href)}
<li>
<a
href={item.href}
@ -130,7 +140,7 @@
</nav>
<!-- Main content -->
<main class="flex-1 overflow-auto p-4 md:p-8">
<main class="app-main">
{@render children()}
</main>
</div>

View file

@ -1,85 +1,137 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data }: { data: PageData } = $props();
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
const stateTone: Record<string, string> = {
excellent: 'state-pill state-pill--excellent',
good: 'state-pill state-pill--good',
fair: 'state-pill state-pill--fair',
poor: 'state-pill state-pill--poor'
};
const routineTone: Record<string, string> = {
am: 'routine-pill routine-pill--am',
pm: 'routine-pill routine-pill--pm'
};
function humanize(text: string): string {
return text
.split('_')
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
.join(' ');
}
const routineStats = $derived.by(() => {
const amCount = data.recentRoutines.filter((routine) => routine.part_of_day === 'am').length;
const pmCount = data.recentRoutines.length - amCount;
return { amCount, pmCount };
});
</script>
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
<div class="space-y-8">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.dashboard_title()}</h2>
<p class="text-muted-foreground">{m.dashboard_subtitle()}</p>
</div>
<div class="editorial-dashboard">
<div class="editorial-atmosphere" aria-hidden="true"></div>
<div class="grid gap-6 md:grid-cols-2">
<!-- Latest skin snapshot -->
<Card>
<CardHeader>
<CardTitle>{m["dashboard_latestSnapshot"]()}</CardTitle>
</CardHeader>
<CardContent>
{#if data.latestSnapshot}
{@const s = data.latestSnapshot}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
{#if s.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
{s.overall_state}
</span>
{/if}
</div>
{#if s.active_concerns.length}
<div class="flex flex-wrap gap-1">
{#each s.active_concerns as concern (concern)}
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
{#if s.notes}
<p class="text-sm text-muted-foreground">{s.notes}</p>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["dashboard_noSnapshots"]()}</p>
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.dashboard_title()}</h2>
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p>
{#if data.latestSnapshot}
{@const snapshot = data.latestSnapshot}
<div class="hero-strip">
<div>
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p>
<p class="hero-strip-value">{snapshot.snapshot_date}</p>
</div>
{#if snapshot.overall_state}
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
{humanize(snapshot.overall_state)}
</span>
{/if}
</CardContent>
</Card>
</div>
{/if}
</section>
<!-- Recent routines -->
<Card>
<CardHeader>
<CardTitle>{m["dashboard_recentRoutines"]()}</CardTitle>
</CardHeader>
<CardContent>
{#if data.recentRoutines.length}
<ul class="space-y-2">
{#each data.recentRoutines as routine (routine.id)}
<li class="flex items-center justify-between">
<a href="/routines/{routine.id}" class="text-sm hover:underline">
{routine.routine_date}
</a>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</li>
<div class="editorial-grid">
<section class="editorial-panel reveal-2">
<header class="panel-header">
<p class="panel-index">01</p>
<h3>{m["dashboard_latestSnapshot"]()}</h3>
</header>
<div class="panel-action-row">
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
</div>
{#if data.latestSnapshot}
{@const s = data.latestSnapshot}
<div class="snapshot-meta-row">
<span class="snapshot-date">{s.snapshot_date}</span>
{#if s.overall_state}
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
{/if}
</div>
{#if s.active_concerns.length}
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}>
{#each s.active_concerns as concern (concern)}
<span class="concern-chip">{humanize(concern)}</span>
{/each}
</ul>
{:else}
<p class="text-sm text-muted-foreground">{m["dashboard_noRoutines"]()}</p>
</div>
{/if}
</CardContent>
</Card>
{#if s.notes}
<p class="snapshot-notes">{s.notes}</p>
{/if}
{:else}
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p>
{/if}
</section>
<section class="editorial-panel reveal-3">
<header class="panel-header">
<p class="panel-index">02</p>
<h3>{m["dashboard_recentRoutines"]()}</h3>
</header>
{#if data.recentRoutines.length}
<div class="routine-summary-strip">
<span class="routine-summary-chip">AM {routineStats.amCount}</span>
<span class="routine-summary-chip">PM {routineStats.pmCount}</span>
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
</div>
<ol class="routine-list">
{#each data.recentRoutines as routine (routine.id)}
<li class="routine-item">
<a href={resolve(`/routines/${routine.id}`)} class="routine-link">
<div class="routine-main">
<div class="routine-topline">
<span class="routine-date">{routine.routine_date}</span>
<span class={routineTone[routine.part_of_day] ?? 'routine-pill'}>
{routine.part_of_day.toUpperCase()}
</span>
</div>
<div class="routine-meta">
<span>{routine.steps?.length ?? 0} {m.common_steps()}</span>
{#if routine.notes}
<span class="routine-note-inline">{m.routines_notes()}: {routine.notes}</span>
{/if}
</div>
</div>
</a>
</li>
{/each}
</ol>
{:else}
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p>
<div class="empty-actions">
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
</div>
{/if}
</section>
</div>
</div>

View file

@ -5,10 +5,11 @@
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { baseSelectClass } from '$lib/components/forms/form-classes';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import {
Table,
TableBody,
@ -21,68 +22,67 @@
let { data, form }: { data: PageData; form: ActionData } = $props();
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
const flagColors: Record<string, string> = {
N: 'bg-green-100 text-green-800',
ABN: 'bg-red-100 text-red-800',
POS: 'bg-orange-100 text-orange-800',
NEG: 'bg-blue-100 text-blue-800',
L: 'bg-yellow-100 text-yellow-800',
H: 'bg-red-100 text-red-800'
const flagPills: Record<string, string> = {
N: 'health-flag-pill health-flag-pill--normal',
ABN: 'health-flag-pill health-flag-pill--abnormal',
POS: 'health-flag-pill health-flag-pill--positive',
NEG: 'health-flag-pill health-flag-pill--negative',
L: 'health-flag-pill health-flag-pill--low',
H: 'health-flag-pill health-flag-pill--high'
};
let showForm = $state(false);
let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? '');
const flagOptions = flags.map((f) => ({ value: f, label: f }));
function onFlagChange(v: string) {
const base = resolve('/health/lab-results');
const url = v ? base + '?flag=' + v : base;
goto(url, { replaceState: true });
const target = v ? `${base}?flag=${encodeURIComponent(v)}` : base;
goto(target, { replaceState: true });
}
</script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m["labResults_title"]()}</h2>
<p class="text-muted-foreground">{m["labResults_count"]({ count: data.results.length })}</p>
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["labResults_title"]()}</h2>
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.results.length })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/health/medications')} variant="outline">{m.medications_title()}</Button>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
</Button>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["labResults_added"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
{/if}
<!-- Filter -->
<div class="flex items-center gap-3">
<div class="editorial-panel reveal-2 flex items-center gap-3">
<span class="text-sm text-muted-foreground">{m["labResults_flagFilter"]()}</span>
<Select
type="single"
<select
class={`${baseSelectClass} w-32`}
value={filterFlag}
onValueChange={onFlagChange}
onchange={(e) => onFlagChange(e.currentTarget.value)}
>
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
<SelectContent>
<SelectItem value="">{m["labResults_flagAll"]()}</SelectItem>
{#each flags as f (f)}
<SelectItem value={f}>{f}</SelectItem>
{/each}
</SelectContent>
</Select>
<option value="">{m["labResults_flagAll"]()}</option>
{#each flags as f (f)}
<option value={f}>{f}</option>
{/each}
</select>
</div>
{#if showForm}
<Card>
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label>
@ -108,29 +108,23 @@
<Label for="unit_original">{m["labResults_unit"]()}</Label>
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
</div>
<div class="space-y-1">
<Label>{m["labResults_flag"]()}</Label>
<input type="hidden" name="flag" value={selectedFlag} />
<Select type="single" value={selectedFlag} onValueChange={(v) => (selectedFlag = v)}>
<SelectTrigger>{selectedFlag || m["labResults_flagNone"]()}</SelectTrigger>
<SelectContent>
<SelectItem value="">{m["labResults_flagNone"]()}</SelectItem>
{#each flags as f (f)}
<SelectItem value={f}>{f}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<SimpleSelect
id="flag"
name="flag"
label={m["labResults_flag"]()}
options={flagOptions}
placeholder={m["labResults_flagNone"]()}
bind:value={selectedFlag}
/>
<div class="flex items-end">
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
</FormSectionCard>
{/if}
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<div class="products-table-shell hidden md:block reveal-2">
<Table>
<TableHeader>
<TableRow>
@ -159,7 +153,7 @@
</TableCell>
<TableCell>
{#if r.flag}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{:else}
@ -180,13 +174,13 @@
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
<div class="flex flex-col gap-3 md:hidden reveal-3">
{#each data.results as r (r.record_id)}
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
<div class="products-mobile-card flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
{#if r.flag}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{/if}

View file

@ -1,13 +1,14 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -15,12 +16,12 @@
let showForm = $state(false);
let kind = $state('supplement');
const kindColors: Record<string, string> = {
prescription: 'bg-purple-100 text-purple-800',
otc: 'bg-blue-100 text-blue-800',
supplement: 'bg-green-100 text-green-800',
herbal: 'bg-emerald-100 text-emerald-800',
other: 'bg-gray-100 text-gray-700'
const kindPills: Record<string, string> = {
prescription: 'health-kind-pill health-kind-pill--prescription',
otc: 'health-kind-pill health-kind-pill--otc',
supplement: 'health-kind-pill health-kind-pill--supplement',
herbal: 'health-kind-pill health-kind-pill--herbal',
other: 'health-kind-pill health-kind-pill--other'
};
const kindLabels: Record<string, () => string> = {
@ -30,44 +31,43 @@
herbal: m["medications_kindHerbal"],
other: m["medications_kindOther"]
};
const kindOptions = $derived(kinds.map((k) => ({ value: k, label: kindLabels[k]?.() ?? k })));
</script>
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.medications_title()}</h2>
<p class="text-muted-foreground">{m.medications_count({ count: data.medications.length })}</p>
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.medications_title()}</h2>
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/health/lab-results')} variant="outline">{m["labResults_title"]()}</Button>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["medications_addNew"]()}
</Button>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["medications_addNew"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.medications_added()}</div>
<div class="editorial-alert editorial-alert--success">{m.medications_added()}</div>
{/if}
{#if showForm}
<Card>
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1 col-span-2">
<Label>{m.medications_kind()}</Label>
<input type="hidden" name="kind" value={kind} />
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
<SelectTrigger>{kindLabels[kind]?.() ?? kind}</SelectTrigger>
<SelectContent>
{#each kinds as k (k)}
<SelectItem value={k}>{kindLabels[k]?.() ?? k}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="col-span-2">
<SimpleSelect
id="kind"
name="kind"
label={m.medications_kind()}
options={kindOptions}
bind:value={kind}
/>
</div>
<div class="space-y-1">
<Label for="product_name">{m["medications_productName"]()}</Label>
@ -85,16 +85,15 @@
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
</FormSectionCard>
{/if}
<div class="space-y-3">
<div class="editorial-panel reveal-2 space-y-3">
{#each data.medications as med (med.record_id)}
<div class="rounded-md border border-border px-4 py-3">
<div class="health-entry-row">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {kindColors[med.kind] ?? ''}">
<span class={kindPills[med.kind] ?? 'health-kind-pill'}>
{kindLabels[med.kind]?.() ?? med.kind}
</span>
<span class="font-medium">{med.product_name}</span>

View file

@ -113,7 +113,7 @@
}
function formatTier(value?: string): string {
if (!value) return 'n/a';
if (!value) return m.common_unknown();
if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid']();
if (value === 'premium') return m['productForm_pricePremium']();
@ -125,64 +125,69 @@
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
}
function formatCategory(value: string): string {
return value.replace(/_/g, ' ');
}
</script>
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div>
<div class="flex gap-2">
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.products_title()}</h2>
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div>
</div>
</section>
<div class="flex flex-wrap gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
size="sm"
onclick={() => ownershipFilter = f}
>
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
</Button>
{/each}
</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 class="editorial-panel reveal-2">
<div class="editorial-filter-row">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
size="sm"
onclick={() => ownershipFilter = f}
>
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
</Button>
{/each}
</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 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>
</div>
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<div class="products-table-shell hidden md:block reveal-2">
<Table>
<TableHeader>
<TableRow>
@ -202,9 +207,9 @@
</TableRow>
{:else}
{#each groupedProducts as [category, products] (category)}
<TableRow class="bg-muted/30 hover:bg-muted/30">
<TableRow class="products-category-row">
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{category.replace(/_/g, ' ')}
{formatCategory(category)}
</TableCell>
</TableRow>
{#each products as product (product.id)}
@ -241,18 +246,18 @@
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
<div class="flex flex-col gap-3 md:hidden reveal-3">
{#if totalCount === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else}
{#each groupedProducts as [category, products] (category)}
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category.replace(/_/g, ' ')}
<div class="products-section-title">
{formatCategory(category)}
</div>
{#each products as product (product.id)}
<a
href={resolve(`/products/${product.id}`)}
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'}`}
class={`products-mobile-card ${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="min-w-0">

View file

@ -53,7 +53,7 @@
}
function formatTier(value?: string): string {
if (!value) return 'n/a';
if (!value) return m.common_unknown();
if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid']();
if (value === 'premium') return m['productForm_pricePremium']();
@ -64,7 +64,7 @@
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
<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 class="products-sticky-actions 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">
<Button
type="submit"
form="product-edit-form"
@ -89,32 +89,29 @@
</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 class="editorial-page space-y-4 pb-20 md:pb-0">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="break-words editorial-title">{product.name}</h2>
<div class="products-meta-strip">
<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>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.success}
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m.common_saved()}</div>
<div class="editorial-alert editorial-alert--success">{m.common_saved()}</div>
{/if}
<Tabs bind:value={activeTab} class="space-y-2">
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
<Boxes class="size-4" aria-hidden="true" />
@ -136,13 +133,13 @@
</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>
<div class="editorial-alert editorial-alert--success">{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>
<div class="editorial-alert editorial-alert--success">{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>
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
{/if}
{#if showInventoryForm}
@ -334,7 +331,7 @@
<ProductForm
bind:this={productFormRef}
{product}
bind:dirty={isEditDirty}
onDirtyChange={(dirty) => (isEditDirty = dirty)}
saveVersion={editSaveVersion}
showAiTrigger={false}
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}

View file

@ -20,19 +20,20 @@
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<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>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["products_newTitle"]()}</h2>
</section>
{#if form?.error}
<div bind:this={errorEl} class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div bind:this={errorEl} class="editorial-alert editorial-alert--error">
{form.error}
</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<form method="POST" use:enhance class="editorial-panel reveal-2 space-y-6 p-4">
<ProductForm />
<div class="flex gap-3 pb-6">

View file

@ -32,17 +32,18 @@
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<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>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
</section>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
{/if}
<Card>
<Card class="reveal-2">
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
@ -63,14 +64,14 @@
{#if suggestions && suggestions.length > 0}
{#if reasoning}
<Card class="border-muted bg-muted/30">
<Card class="border-muted bg-muted/30 reveal-3">
<CardContent class="pt-4">
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
<div class="space-y-4 reveal-3">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>

View file

@ -22,28 +22,27 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.routines_title()}</h2>
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
<div class="editorial-toolbar">
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
</div>
</div>
</section>
{#if sortedDates.length}
<div class="space-y-4">
{#each sortedDates as date}
<div class="editorial-panel reveal-2 space-y-4">
{#each sortedDates as date (date)}
<div>
<h3 class="text-sm font-semibold text-muted-foreground mb-2">{date}</h3>
<h3 class="products-section-title mb-2">{date}</h3>
<div class="space-y-1">
{#each byDate[date] as routine}
{#each byDate[date] as routine (routine.id)}
<a
href="/routines/{routine.id}"
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors"
href={resolve(`/routines/${routine.id}`)}
class="routine-ledger-row"
>
<div class="flex items-center gap-3">
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
@ -61,6 +60,8 @@
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_noRoutines"]()}</p>
<div class="editorial-panel reveal-2">
<p class="empty-copy">{m["routines_noRoutines"]()}</p>
</div>
{/if}
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types';
@ -10,14 +11,8 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select';
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
@ -137,21 +132,36 @@
.filter((c) => groups.has(c))
.map((c) => [c, groups.get(c)!] as const);
});
const groupedProductOptions = $derived(
groupedProducts.map(([cat, items]) => ({
label: formatCategory(cat),
options: items.map((p) => ({ value: p.id, label: `${p.name} · ${p.brand}` }))
}))
);
const groomingActionOptions = GROOMING_ACTIONS.map((action) => ({
value: action,
label: action.replace(/_/g, ' ')
}));
</script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<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>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<div class="flex items-center gap-3">
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if routine.notes}
@ -159,7 +169,7 @@
{/if}
<!-- Steps -->
<div class="space-y-3">
<div class="editorial-panel reveal-2 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
@ -172,29 +182,14 @@
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<input type="hidden" name="product_id" value={selectedProductId} />
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
<SelectTrigger>
{#if selectedProductId}
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<GroupedSelect
id="new_step_product"
name="product_id"
label={m.routines_product()}
groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
bind:value={selectedProductId}
/>
<input type="hidden" name="order_index" value={nextOrderIndex} />
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
@ -226,32 +221,14 @@
<div class="px-4 py-3 space-y-3">
{#if step.product_id !== undefined}
<!-- Product step: change product / dose / region -->
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<Select
type="single"
value={editDraft.product_id ?? ''}
onValueChange={(v) => (editDraft.product_id = v || undefined)}
>
<SelectTrigger>
{#if editDraft.product_id}
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<GroupedSelect
id={`edit_step_product_${step.id}`}
label={m.routines_product()}
groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
value={editDraft.product_id ?? ''}
onChange={(value) => (editDraft.product_id = value || undefined)}
/>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label>{m.routines_dose()}</Label>
@ -272,24 +249,15 @@
</div>
{:else}
<!-- Action step: change action_type / notes -->
<div class="space-y-1">
<Label>{m["routines_action"]()}</Label>
<Select
type="single"
value={editDraft.action_type ?? ''}
onValueChange={(v) =>
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
>
<SelectTrigger>
{editDraft.action_type?.replace(/_/g, ' ') ?? m["routines_selectAction"]()}
</SelectTrigger>
<SelectContent>
{#each GROOMING_ACTIONS as action (action)}
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<SimpleSelect
id={`edit_step_action_${step.id}`}
label={m["routines_action"]()}
options={groomingActionOptions}
placeholder={m["routines_selectAction"]()}
value={editDraft.action_type ?? ''}
onChange={(value) =>
(editDraft.action_type = (value || undefined) as GroomingAction | undefined)}
/>
<div class="space-y-1">
<Label>{m.routines_notes()}</Label>
<Input
@ -362,7 +330,7 @@
{/if}
</div>
<Separator />
<Separator class="opacity-50" />
<form
method="POST"

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
@ -46,28 +47,31 @@
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1">
<div class="flex items-center justify-between">
<div>
<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>
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
<p class="editorial-kicker mt-2">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title mt-1 text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
</div>
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
</Button>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryAdded"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryAdded"]()}</div>
{/if}
{#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryUpdated"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryUpdated"]()}</div>
{/if}
{#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryDeleted"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryDeleted"]()}</div>
{/if}
<!-- Add form -->

View file

@ -1,50 +1,51 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
let partOfDay = $state('am');
const partOfDayOptions = [
{ value: 'am', label: m.common_am() },
{ value: 'pm', label: m.common_pm() }
];
</script>
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-md space-y-6">
<div class="flex items-center gap-4">
<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>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
<Card>
<CardHeader><CardTitle>{m["routines_detailsTitle"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["routines_detailsTitle"]()} className="reveal-2">
<form method="POST" use:enhance class="space-y-5">
<div class="space-y-2">
<Label for="routine_date">{m.routines_date()}</Label>
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
</div>
<div class="space-y-2">
<Label>{m["routines_amOrPm"]()}</Label>
<input type="hidden" name="part_of_day" value={partOfDay} />
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
<SelectContent>
<SelectItem value="am">{m.common_am()}</SelectItem>
<SelectItem value="pm">{m.common_pm()}</SelectItem>
</SelectContent>
</Select>
</div>
<SimpleSelect
id="part_of_day"
name="part_of_day"
label={m["routines_amOrPm"]()}
options={partOfDayOptions}
bind:value={partOfDay}
/>
<div class="space-y-2">
<Label for="notes">{m.routines_notes()}</Label>
@ -53,9 +54,8 @@
<div class="flex gap-3 pt-2">
<Button type="submit">{m["routines_createRoutine"]()}</Button>
<Button variant="outline" href="/routines">{m.common_cancel()}</Button>
<Button variant="outline" href={resolve('/routines')}>{m.common_cancel()}</Button>
</div>
</form>
</CardContent>
</Card>
</FormSectionCard>
</div>

View file

@ -7,10 +7,13 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Card, CardContent } from '$lib/components/ui/card';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { baseTextareaClass } from '$lib/components/forms/form-classes';
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
@ -34,6 +37,13 @@
let loadingBatch = $state(false);
let loadingSave = $state(false);
const textareaClass = `${baseTextareaClass} resize-none`;
const partOfDayOptions = [
{ value: 'am', label: m["suggest_amMorning"]() },
{ value: 'pm', label: m["suggest_pmEvening"]() }
];
function stepLabel(step: SuggestedStep): string {
if (step.product_id && productMap[step.product_id]) {
const p = productMap[step.product_id];
@ -104,17 +114,18 @@
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<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>
</div>
<div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.suggest_title()}</h2>
</section>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
{/if}
<Tabs value="single">
<Tabs value="single" class="reveal-2 editorial-tabs">
<TabsList class="w-full">
<TabsTrigger value="single" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_singleTab"]()}</TabsTrigger>
<TabsTrigger value="batch" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_batchTab"]()}</TabsTrigger>
@ -122,26 +133,20 @@
<!-- ── Single tab ─────────────────────────────────────────────────── -->
<TabsContent value="single" class="space-y-6 pt-4">
<Card>
<CardHeader><CardTitle class="text-base">{m["suggest_singleParams"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["suggest_singleParams"]()}>
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="single_date">{m.suggest_date()}</Label>
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
</div>
<div class="space-y-2">
<Label>{m["suggest_timeOfDay"]()}</Label>
<input type="hidden" name="part_of_day" value={partOfDay} />
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}>
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
<SelectContent>
<SelectItem value="am">{m["suggest_amMorning"]()}</SelectItem>
<SelectItem value="pm">{m["suggest_pmEvening"]()}</SelectItem>
</SelectContent>
</Select>
</div>
<SimpleSelect
id="single_part_of_day"
name="part_of_day"
label={m["suggest_timeOfDay"]()}
options={partOfDayOptions}
bind:value={partOfDay}
/>
</div>
<div class="space-y-2">
@ -151,35 +156,23 @@
name="notes"
rows="2"
placeholder={m["suggest_contextPlaceholder"]()}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
class={textareaClass}
></textarea>
</div>
{#if partOfDay === 'am'}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
<HintCheckbox
id="single_leaving_home"
name="leaving_home"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
label={m["suggest_leavingHomeLabel"]()}
hint={m["suggest_leavingHomeHint"]()}
/>
<div class="space-y-0.5">
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
</div>
</div>
{/if}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<HintCheckbox
id="single_include_minoxidil_beard"
name="include_minoxidil_beard"
label={m["suggest_minoxidilToggleLabel"]()}
hint={m["suggest_minoxidilToggleHint"]()}
/>
<Button type="submit" disabled={loadingSingle} class="w-full">
{#if loadingSingle}
@ -190,8 +183,7 @@
{/if}
</Button>
</form>
</CardContent>
</Card>
</FormSectionCard>
{#if suggestion}
<div class="space-y-4">
@ -272,9 +264,7 @@
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
<TabsContent value="batch" class="space-y-6 pt-4">
<Card>
<CardHeader><CardTitle class="text-base">{m["suggest_batchRange"]()}</CardTitle></CardHeader>
<CardContent>
<FormSectionCard title={m["suggest_batchRange"]()}>
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
@ -294,33 +284,21 @@
name="notes"
rows="2"
placeholder={m["suggest_batchContextPlaceholder"]()}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
class={textareaClass}
></textarea>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_minimize_products"
name="minimize_products"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_minimize_products" class="font-medium">{m["suggest_minimizeProductsLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minimizeProductsHint"]()}</p>
</div>
</div>
<HintCheckbox
id="batch_include_minoxidil_beard"
name="include_minoxidil_beard"
label={m["suggest_minoxidilToggleLabel"]()}
hint={m["suggest_minoxidilToggleHint"]()}
/>
<HintCheckbox
id="batch_minimize_products"
name="minimize_products"
label={m["suggest_minimizeProductsLabel"]()}
hint={m["suggest_minimizeProductsHint"]()}
/>
<Button type="submit" disabled={loadingBatch} class="w-full">
{#if loadingBatch}
@ -331,8 +309,7 @@
{/if}
</Button>
</form>
</CardContent>
</Card>
</FormSectionCard>
{#if batch}
<div class="space-y-4">

View file

@ -6,9 +6,8 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Sparkles, Pencil, X } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -18,11 +17,11 @@
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
const statePills: Record<string, string> = {
excellent: 'state-pill state-pill--excellent',
good: 'state-pill state-pill--good',
fair: 'state-pill state-pill--fair',
poor: 'state-pill state-pill--poor'
};
const stateLabels: Record<string, () => string> = {
@ -109,6 +108,11 @@
let aiLoading = $state(false);
let aiError = $state('');
const overallStateOptions = $derived(states.map((s) => ({ value: s, label: stateLabels[s]?.() ?? s })));
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
);
@ -168,13 +172,12 @@
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
<svelte:window onkeydown={handleModalKeydown} />
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<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>
</div>
<div class="flex items-center gap-2">
<div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.skin_title()}</h2>
<p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p>
<div class="editorial-toolbar">
{#if showForm}
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
<Sparkles class="size-4" />
@ -185,19 +188,19 @@
{showForm ? m.common_cancel() : m["skin_addNew"]()}
</Button>
</div>
</div>
</section>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotAdded"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotAdded"]()}</div>
{/if}
{#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotUpdated"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotUpdated"]()}</div>
{/if}
{#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotDeleted"]()}</div>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotDeleted"]()}</div>
{/if}
{#if showForm}
@ -249,96 +252,109 @@
{/if}
<!-- New snapshot form -->
<Card>
<Card class="reveal-2">
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="snapshot_date">{m.skin_date()}</Label>
<Input
id="snapshot_date"
name="snapshot_date"
type="date"
bind:value={snapshotDate}
required
/>
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={overallState} />
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={skinType} />
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={barrierState} />
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="hydration_level">{m.skin_hydration()}</Label>
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
</div>
<div class="space-y-1">
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
</div>
<div class="space-y-1">
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="priorities">{m["skin_priorities"]()}</Label>
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">{m.skin_notes()}</Label>
<Input id="notes" name="notes" bind:value={notes} />
</div>
<LabeledInputField
id="snapshot_date"
name="snapshot_date"
label={m.skin_date()}
type="date"
required
bind:value={snapshotDate}
/>
<SimpleSelect
id="overall_state"
name="overall_state"
label={m["skin_overallState"]()}
options={overallStateOptions}
placeholder={m.common_select()}
bind:value={overallState}
/>
<SimpleSelect
id="texture"
name="texture"
label={m.skin_texture()}
options={textureOptions}
placeholder={m.common_select()}
bind:value={texture}
/>
<SimpleSelect
id="skin_type"
name="skin_type"
label={m["skin_skinType"]()}
options={skinTypeOptions}
placeholder={m.common_select()}
bind:value={skinType}
/>
<SimpleSelect
id="barrier_state"
name="barrier_state"
label={m["skin_barrierState"]()}
options={barrierOptions}
placeholder={m.common_select()}
bind:value={barrierState}
/>
<LabeledInputField
id="hydration_level"
name="hydration_level"
label={m.skin_hydration()}
type="number"
min="1"
max="5"
bind:value={hydrationLevel}
/>
<LabeledInputField
id="sensitivity_level"
name="sensitivity_level"
label={m.skin_sensitivity()}
type="number"
min="1"
max="5"
bind:value={sensitivityLevel}
/>
<LabeledInputField
id="sebum_tzone"
name="sebum_tzone"
label={m["skin_sebumTzone"]()}
type="number"
min="1"
max="5"
bind:value={sebumTzone}
/>
<LabeledInputField
id="sebum_cheeks"
name="sebum_cheeks"
label={m["skin_sebumCheeks"]()}
type="number"
min="1"
max="5"
bind:value={sebumCheeks}
/>
<LabeledInputField
id="active_concerns"
name="active_concerns"
label={m["skin_activeConcerns"]()}
placeholder={m["skin_activeConcernsPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={activeConcernsRaw}
/>
<LabeledInputField
id="priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={prioritiesRaw}
/>
<LabeledInputField
id="notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={notes}
/>
<div class="col-span-2">
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
</div>
@ -347,7 +363,7 @@
</Card>
{/if}
<div class="space-y-4">
<div class="space-y-4 reveal-2">
{#each sortedSnapshots as snap (snap.id)}
<Card>
<CardContent class="pt-4">
@ -363,86 +379,105 @@
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<input type="hidden" name="id" value={snap.id} />
<div class="space-y-1">
<Label for="edit_snapshot_date">{m.skin_date()}</Label>
<Input id="edit_snapshot_date" name="snapshot_date" type="date" bind:value={editSnapshotDate} required />
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={editOverallState} />
<Select type="single" value={editOverallState} onValueChange={(v) => (editOverallState = v)}>
<SelectTrigger>{editOverallState ? stateLabels[editOverallState]?.() ?? editOverallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={editTexture} />
<Select type="single" value={editTexture} onValueChange={(v) => (editTexture = v)}>
<SelectTrigger>{editTexture ? textureLabels[editTexture]?.() ?? editTexture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={editSkinType} />
<Select type="single" value={editSkinType} onValueChange={(v) => (editSkinType = v)}>
<SelectTrigger>{editSkinType ? skinTypeLabels[editSkinType]?.() ?? editSkinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={editBarrierState} />
<Select type="single" value={editBarrierState} onValueChange={(v) => (editBarrierState = v)}>
<SelectTrigger>{editBarrierState ? barrierLabels[editBarrierState]?.() ?? editBarrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="edit_hydration_level">{m.skin_hydration()}</Label>
<Input id="edit_hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={editHydrationLevel} />
</div>
<div class="space-y-1">
<Label for="edit_sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="edit_sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={editSensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="edit_sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="edit_sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={editSebumTzone} />
</div>
<div class="space-y-1">
<Label for="edit_sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="edit_sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={editSebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_notes">{m.skin_notes()}</Label>
<Input id="edit_notes" name="notes" bind:value={editNotes} />
</div>
<LabeledInputField
id="edit_snapshot_date"
name="snapshot_date"
label={m.skin_date()}
type="date"
required
bind:value={editSnapshotDate}
/>
<SimpleSelect
id="edit_overall_state"
name="overall_state"
label={m["skin_overallState"]()}
options={overallStateOptions}
placeholder={m.common_select()}
bind:value={editOverallState}
/>
<SimpleSelect
id="edit_texture"
name="texture"
label={m.skin_texture()}
options={textureOptions}
placeholder={m.common_select()}
bind:value={editTexture}
/>
<SimpleSelect
id="edit_skin_type"
name="skin_type"
label={m["skin_skinType"]()}
options={skinTypeOptions}
placeholder={m.common_select()}
bind:value={editSkinType}
/>
<SimpleSelect
id="edit_barrier_state"
name="barrier_state"
label={m["skin_barrierState"]()}
options={barrierOptions}
placeholder={m.common_select()}
bind:value={editBarrierState}
/>
<LabeledInputField
id="edit_hydration_level"
name="hydration_level"
label={m.skin_hydration()}
type="number"
min="1"
max="5"
bind:value={editHydrationLevel}
/>
<LabeledInputField
id="edit_sensitivity_level"
name="sensitivity_level"
label={m.skin_sensitivity()}
type="number"
min="1"
max="5"
bind:value={editSensitivityLevel}
/>
<LabeledInputField
id="edit_sebum_tzone"
name="sebum_tzone"
label={m["skin_sebumTzone"]()}
type="number"
min="1"
max="5"
bind:value={editSebumTzone}
/>
<LabeledInputField
id="edit_sebum_cheeks"
name="sebum_cheeks"
label={m["skin_sebumCheeks"]()}
type="number"
min="1"
max="5"
bind:value={editSebumCheeks}
/>
<LabeledInputField
id="edit_active_concerns"
name="active_concerns"
label={m["skin_activeConcerns"]()}
placeholder={m["skin_activeConcernsPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={editActiveConcernsRaw}
/>
<LabeledInputField
id="edit_priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={editPrioritiesRaw}
/>
<LabeledInputField
id="edit_notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={editNotes}
/>
<div class="col-span-2 flex gap-2">
<Button type="submit">{m.common_save()}</Button>
<Button type="button" variant="outline" onclick={() => (editingId = null)}>{m.common_cancel()}</Button>
@ -463,10 +498,10 @@
</div>
{#if snap.overall_state || snap.texture}
<div class="flex flex-wrap items-center gap-1.5">
{#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span>
{#if snap.overall_state}
<span class={statePills[snap.overall_state] ?? 'state-pill'}>
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span>
{/if}
{#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>

View file

@ -1,15 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import type { Plugin, Rollup } from 'vite';
import { defineConfig } from 'vite';
import { paraglideVitePlugin } from '@inlang/paraglide-js';
const stripDeprecatedRollupOptions = {
const stripDeprecatedRollupOptions: Plugin = {
name: 'strip-deprecated-rollup-options',
outputOptions(options: Record<string, unknown>) {
if ('codeSplitting' in options) {
delete options.codeSplitting;
outputOptions(options: Rollup.OutputOptions) {
const nextOptions = { ...options } as Rollup.OutputOptions & { codeSplitting?: unknown };
if ('codeSplitting' in nextOptions) {
delete nextOptions.codeSplitting;
}
return options;
return nextOptions;
}
};