feat(frontend): unify editorial UI and DRY form architecture
This commit is contained in:
parent
d4fbc1faf5
commit
693c6a9626
35 changed files with 2600 additions and 1180 deletions
189
docs/frontend-design-cookbook.md
Normal file
189
docs/frontend-design-cookbook.md
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
# Frontend Design Cookbook
|
||||||
|
|
||||||
|
This cookbook defines the visual system for the frontend so every new change extends the existing style instead of inventing a new one.
|
||||||
|
|
||||||
|
## Design intent
|
||||||
|
|
||||||
|
- Core tone: light editorial, calm and information-first.
|
||||||
|
- Product feel: premium personal logbook, not generic SaaS dashboard.
|
||||||
|
- Contrast model: neutral paper and ink do most of the work; accents are restrained.
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
|
||||||
|
- Keep layouts readable first. Aesthetic details support hierarchy, not the other way around.
|
||||||
|
- Use one visual language across the app shell, cards, forms, tables, and actions.
|
||||||
|
- Prefer subtle depth (borders, layered paper, soft shadows) over loud gradients.
|
||||||
|
- Keep motion purposeful and short; always preserve `prefers-reduced-motion` behavior.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- Display/headings: `Cormorant Infant`.
|
||||||
|
- Body/UI text: `Manrope`.
|
||||||
|
- Use display typography for page titles and section heads only.
|
||||||
|
- Keep paragraph text in body font for legibility.
|
||||||
|
|
||||||
|
## Color system
|
||||||
|
|
||||||
|
Global neutrals are defined in `frontend/src/app.css` using CSS variables.
|
||||||
|
|
||||||
|
- `--background`, `--card`, `--foreground`, `--muted-foreground`, `--border`
|
||||||
|
- `--page-accent` drives route-level emphasis.
|
||||||
|
- `--page-accent-soft` is the low-contrast companion tint.
|
||||||
|
|
||||||
|
### Domain accents (muted and professional)
|
||||||
|
|
||||||
|
- Dashboard: `--accent-dashboard`
|
||||||
|
- Products: `--accent-products`
|
||||||
|
- Routines: `--accent-routines`
|
||||||
|
- Skin: `--accent-skin`
|
||||||
|
- Health labs: `--accent-health-labs`
|
||||||
|
- Health medications: `--accent-health-meds`
|
||||||
|
|
||||||
|
The app shell assigns a domain class per route and maps it to `--page-accent`.
|
||||||
|
|
||||||
|
## Where to use accent color
|
||||||
|
|
||||||
|
Use accent for:
|
||||||
|
|
||||||
|
- active navigation state
|
||||||
|
- important buttons and key badges
|
||||||
|
- focus ring tint and hover border tint
|
||||||
|
- section separators and small status markers
|
||||||
|
|
||||||
|
Do not use accent for:
|
||||||
|
|
||||||
|
- full-page backgrounds
|
||||||
|
- body text
|
||||||
|
- large surfaces that reduce readability
|
||||||
|
|
||||||
|
Guideline: accent should occupy roughly 10-15% of visual area per screen.
|
||||||
|
|
||||||
|
## Layout rules
|
||||||
|
|
||||||
|
- Use the app shell spacing rhythm from `app.css` (`.app-main`, editorial cards).
|
||||||
|
- Keep max content width constrained for readability.
|
||||||
|
- Prefer asymmetry in hero/summary areas, but keep forms and dense data grids regular.
|
||||||
|
|
||||||
|
### Shared page wrappers
|
||||||
|
|
||||||
|
Use these wrappers before introducing route-specific structure:
|
||||||
|
|
||||||
|
- `editorial-page`: standard constrained content width for route pages.
|
||||||
|
- `editorial-hero`: top summary strip for title, subtitle, and primary actions.
|
||||||
|
- `editorial-panel`: primary surface for forms, tables, and ledgers.
|
||||||
|
- `editorial-toolbar`: compact action row under hero copy.
|
||||||
|
- `editorial-backlink`: standard top-left back navigation style.
|
||||||
|
- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`: feedback banners.
|
||||||
|
|
||||||
|
## Component rules
|
||||||
|
|
||||||
|
- Reuse UI primitives under `frontend/src/lib/components/ui/*`.
|
||||||
|
- Keep primitive APIs stable when changing visual treatment.
|
||||||
|
- Style via tokens and shared classes, not one-off hardcoded colors.
|
||||||
|
- New variants must be documented in this file.
|
||||||
|
|
||||||
|
### Existing shared utility patterns
|
||||||
|
|
||||||
|
These classes are already in use and should be reused:
|
||||||
|
|
||||||
|
- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row`
|
||||||
|
- Group headers: `products-section-title`
|
||||||
|
- Table shell: `products-table-shell`
|
||||||
|
- Tabs shell: `products-tabs`, `editorial-tabs`
|
||||||
|
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
|
||||||
|
|
||||||
|
## Forms and data views
|
||||||
|
|
||||||
|
- Inputs should remain high-contrast and calm.
|
||||||
|
- Validation/error states should be explicit and never color-only.
|
||||||
|
- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata.
|
||||||
|
|
||||||
|
### DRY form primitives
|
||||||
|
|
||||||
|
- Use shared form components for repeated native select markup:
|
||||||
|
- `frontend/src/lib/components/forms/SimpleSelect.svelte`
|
||||||
|
- `frontend/src/lib/components/forms/GroupedSelect.svelte`
|
||||||
|
- Use shared checkbox helper for repeated label+hint toggles:
|
||||||
|
- `frontend/src/lib/components/forms/HintCheckbox.svelte`
|
||||||
|
- Use shared input field helper for repeated label+input rows:
|
||||||
|
- `frontend/src/lib/components/forms/LabeledInputField.svelte`
|
||||||
|
- Use shared section card helper for repeated titled form panels:
|
||||||
|
- `frontend/src/lib/components/forms/FormSectionCard.svelte`
|
||||||
|
- Use shared class tokens from:
|
||||||
|
- `frontend/src/lib/components/forms/form-classes.ts`
|
||||||
|
- Prefer passing option labels from route files via `m.*` to keep i18n explicit.
|
||||||
|
|
||||||
|
### Select policy (performance + maintainability)
|
||||||
|
|
||||||
|
- Default to native `<select>` for enum-style fields and simple pickers.
|
||||||
|
- Use `SimpleSelect` / `GroupedSelect` for consistency and to reduce duplication.
|
||||||
|
- Avoid `ui/select` primitives unless search, custom keyboard behavior, or richer popup UX is truly needed.
|
||||||
|
- For grouped option sets (e.g. products by category), use `<optgroup>` via `GroupedSelect`.
|
||||||
|
|
||||||
|
### Chunk hygiene
|
||||||
|
|
||||||
|
- Large forms should be split into focused section components.
|
||||||
|
- Lazy-load heavyweight modal and optional sections where practical.
|
||||||
|
- After structural UI refactors, run `pnpm build` and inspect output for large chunks.
|
||||||
|
- Track and prevent regressions in known hotspots (form-heavy pages and shared interaction primitives).
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
- Entry animations: short upward reveal with slight stagger.
|
||||||
|
- Hover interactions: subtle translation or tint only.
|
||||||
|
- Never stack multiple strong animations in the same viewport.
|
||||||
|
|
||||||
|
## Accessibility baseline
|
||||||
|
|
||||||
|
- Keyboard navigation for all interactive elements.
|
||||||
|
- Visible focus states on controls and links.
|
||||||
|
- Respect reduced motion.
|
||||||
|
- Maintain readable contrast for text, borders, and controls.
|
||||||
|
|
||||||
|
## i18n rules
|
||||||
|
|
||||||
|
- Do not add hardcoded UI labels in route components when a message key already exists.
|
||||||
|
- Prefer existing `m.*` keys from paraglide for headings, labels, empty states, and helper text.
|
||||||
|
- If a new label is required, add a new translation key instead of shipping English literals.
|
||||||
|
- Keep fallback display values localized (`m.common_unknown()` over hardcoded `n/a`).
|
||||||
|
- Avoid language-specific toggle text in reusable components unless fully localized.
|
||||||
|
|
||||||
|
## Implementation checklist for new UI work
|
||||||
|
|
||||||
|
1. Pick the route domain and rely on `--page-accent`.
|
||||||
|
2. Use existing typography pair and spacing rhythm.
|
||||||
|
3. Build with primitives first; add route-level wrappers only if needed.
|
||||||
|
4. Validate mobile and desktop layout.
|
||||||
|
5. Run:
|
||||||
|
- `pnpm check`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm build` (for significant component architecture changes)
|
||||||
|
|
||||||
|
## File touchpoints
|
||||||
|
|
||||||
|
- Core tokens and global look: `frontend/src/app.css`
|
||||||
|
- App shell and route domain mapping: `frontend/src/routes/+layout.svelte`
|
||||||
|
- Route examples using the pattern:
|
||||||
|
- `frontend/src/routes/+page.svelte`
|
||||||
|
- `frontend/src/routes/products/+page.svelte`
|
||||||
|
- `frontend/src/routes/routines/+page.svelte`
|
||||||
|
- `frontend/src/routes/health/lab-results/+page.svelte`
|
||||||
|
- `frontend/src/routes/skin/+page.svelte`
|
||||||
|
- Primitive visuals:
|
||||||
|
- `frontend/src/lib/components/ui/button/button.svelte`
|
||||||
|
- `frontend/src/lib/components/ui/card/card.svelte`
|
||||||
|
- `frontend/src/lib/components/ui/input/input.svelte`
|
||||||
|
- `frontend/src/lib/components/ui/badge/badge.svelte`
|
||||||
|
|
||||||
|
- Shared form DRY helpers:
|
||||||
|
- `frontend/src/lib/components/forms/SimpleSelect.svelte`
|
||||||
|
- `frontend/src/lib/components/forms/GroupedSelect.svelte`
|
||||||
|
- `frontend/src/lib/components/forms/HintCheckbox.svelte`
|
||||||
|
- `frontend/src/lib/components/forms/LabeledInputField.svelte`
|
||||||
|
- `frontend/src/lib/components/forms/FormSectionCard.svelte`
|
||||||
|
- `frontend/src/lib/components/forms/form-classes.ts`
|
||||||
|
|
||||||
|
- i18n message source:
|
||||||
|
- `frontend/src/lib/paraglide/messages/*`
|
||||||
|
|
||||||
|
When introducing new visual patterns, update this cookbook in the same change.
|
||||||
|
|
@ -5,26 +5,42 @@
|
||||||
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
|
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: hsl(0 0% 100%);
|
--background: hsl(42 35% 95%);
|
||||||
--foreground: hsl(240 10% 3.9%);
|
--foreground: hsl(220 24% 14%);
|
||||||
--card: hsl(0 0% 100%);
|
--card: hsl(44 32% 96%);
|
||||||
--card-foreground: hsl(240 10% 3.9%);
|
--card-foreground: hsl(220 24% 14%);
|
||||||
--popover: hsl(0 0% 100%);
|
--popover: hsl(44 32% 96%);
|
||||||
--popover-foreground: hsl(240 10% 3.9%);
|
--popover-foreground: hsl(220 24% 14%);
|
||||||
--primary: hsl(240 5.9% 10%);
|
--primary: hsl(15 44% 34%);
|
||||||
--primary-foreground: hsl(0 0% 98%);
|
--primary-foreground: hsl(42 40% 97%);
|
||||||
--secondary: hsl(240 4.8% 95.9%);
|
--secondary: hsl(38 24% 91%);
|
||||||
--secondary-foreground: hsl(240 5.9% 10%);
|
--secondary-foreground: hsl(220 20% 20%);
|
||||||
--muted: hsl(240 4.8% 95.9%);
|
--muted: hsl(42 20% 90%);
|
||||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
--muted-foreground: hsl(219 12% 39%);
|
||||||
--accent: hsl(240 4.8% 95.9%);
|
--accent: hsl(42 24% 90%);
|
||||||
--accent-foreground: hsl(240 5.9% 10%);
|
--accent-foreground: hsl(220 24% 14%);
|
||||||
--destructive: hsl(0 84.2% 60.2%);
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
--border: hsl(240 5.9% 90%);
|
--border: hsl(35 23% 76%);
|
||||||
--input: hsl(240 5.9% 90%);
|
--input: hsl(37 20% 80%);
|
||||||
--ring: hsl(240 5.9% 10%);
|
--ring: hsl(15 40% 38%);
|
||||||
--radius: 0.5rem;
|
--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 {
|
.dark {
|
||||||
|
|
@ -86,4 +102,723 @@
|
||||||
body {
|
body {
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
color: var(--foreground);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,26 @@
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { parseProductText, type ProductParseResponse } from '$lib/api';
|
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||||
|
import type { ProductParseResponse } from '$lib/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Sparkles, X } from 'lucide-svelte';
|
import { Sparkles, X } from 'lucide-svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
product,
|
product,
|
||||||
dirty = $bindable(false),
|
|
||||||
saveVersion = 0,
|
saveVersion = 0,
|
||||||
showAiTrigger = true,
|
showAiTrigger = true,
|
||||||
|
onDirtyChange,
|
||||||
computedPriceLabel,
|
computedPriceLabel,
|
||||||
computedPricePerUseLabel,
|
computedPricePerUseLabel,
|
||||||
computedPriceTierLabel
|
computedPriceTierLabel
|
||||||
}: {
|
}: {
|
||||||
product?: Product;
|
product?: Product;
|
||||||
dirty?: boolean;
|
|
||||||
saveVersion?: number;
|
saveVersion?: number;
|
||||||
showAiTrigger?: boolean;
|
showAiTrigger?: boolean;
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
computedPriceLabel?: string;
|
computedPriceLabel?: string;
|
||||||
computedPricePerUseLabel?: string;
|
computedPricePerUseLabel?: string;
|
||||||
computedPriceTierLabel?: string;
|
computedPriceTierLabel?: string;
|
||||||
|
|
@ -140,10 +140,6 @@
|
||||||
{ value: 'false', label: m.common_no() }
|
{ 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([
|
const effectFields = $derived([
|
||||||
{ key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() },
|
{ key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() },
|
||||||
{ key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() },
|
{ key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() },
|
||||||
|
|
@ -201,6 +197,7 @@
|
||||||
aiLoading = true;
|
aiLoading = true;
|
||||||
aiError = '';
|
aiError = '';
|
||||||
try {
|
try {
|
||||||
|
const { parseProductText } = await import('$lib/api');
|
||||||
const r = await parseProductText(aiText);
|
const r = await parseProductText(aiText);
|
||||||
applyAiResult(r);
|
applyAiResult(r);
|
||||||
aiModalOpen = false;
|
aiModalOpen = false;
|
||||||
|
|
@ -379,11 +376,9 @@
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const textareaClass =
|
const textareaClass = `${baseTextareaClass} focus-visible:ring-offset-2`;
|
||||||
'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 selectClass =
|
const selectClass = 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 function openAiModal() {
|
export function openAiModal() {
|
||||||
aiError = '';
|
aiError = '';
|
||||||
|
|
@ -466,11 +461,11 @@
|
||||||
baselineFingerprint = currentFingerprint;
|
baselineFingerprint = currentFingerprint;
|
||||||
baselineProductId = currentProductId;
|
baselineProductId = currentProductId;
|
||||||
baselineSaveVersion = saveVersion;
|
baselineSaveVersion = saveVersion;
|
||||||
dirty = false;
|
onDirtyChange?.(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dirty = currentFingerprint !== baselineFingerprint;
|
onDirtyChange?.(currentFingerprint !== baselineFingerprint);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -496,153 +491,51 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if aiModalOpen}
|
{#if aiModalOpen}
|
||||||
<button
|
{#await import('$lib/components/ProductFormAiModal.svelte') then mod}
|
||||||
type="button"
|
{@const AiModal = mod.default}
|
||||||
class="fixed inset-0 z-50 bg-black/50"
|
<AiModal
|
||||||
onclick={closeAiModal}
|
open={aiModalOpen}
|
||||||
aria-label={m.common_cancel()}
|
bind:aiText
|
||||||
></button>
|
{aiLoading}
|
||||||
<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">
|
aiError={aiError}
|
||||||
<Card class="max-h-full w-full overflow-hidden">
|
{textareaClass}
|
||||||
<CardHeader class="border-b border-border">
|
onClose={closeAiModal}
|
||||||
<div class="flex items-center justify-between gap-3">
|
onSubmit={parseWithAi}
|
||||||
<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()}>
|
{/await}
|
||||||
<X class="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-3 overflow-y-auto p-4">
|
|
||||||
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
|
|
||||||
<textarea bind:value={aiText} rows="8" placeholder={m["productForm_pasteText"]()} class={textareaClass}></textarea>
|
|
||||||
{#if aiError}
|
|
||||||
<p class="text-sm text-destructive">{aiError}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
|
|
||||||
<Button type="button" onclick={parseWithAi} disabled={aiLoading || !aiText.trim()}>
|
|
||||||
{#if aiLoading}
|
|
||||||
{m["productForm_parsing"]()}
|
|
||||||
{:else}
|
|
||||||
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
{#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod}
|
||||||
<Card class={editSection === 'basic' ? '' : 'hidden'}>
|
{@const BasicSection = mod.default}
|
||||||
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
<BasicSection
|
||||||
<CardContent class="space-y-4">
|
visible={editSection === 'basic'}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
bind:name
|
||||||
<div class="space-y-2">
|
bind:brand
|
||||||
<Label for="name">{m["productForm_name"]()}</Label>
|
bind:lineName
|
||||||
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
|
bind:url
|
||||||
</div>
|
bind:sku
|
||||||
<div class="space-y-2">
|
bind:barcode
|
||||||
<Label for="brand">{m["productForm_brand"]()}</Label>
|
/>
|
||||||
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
|
{/await}
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- ── Classification ────────────────────────────────────────────────────── -->
|
{#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod}
|
||||||
<Card class={editSection === 'basic' ? '' : 'hidden'}>
|
{@const ClassificationSection = mod.default}
|
||||||
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
<ClassificationSection
|
||||||
<CardContent>
|
visible={editSection === 'basic'}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{selectClass}
|
||||||
<div class="col-span-2 space-y-2">
|
{categories}
|
||||||
<Label>{m["productForm_category"]()}</Label>
|
{textures}
|
||||||
<input type="hidden" name="category" value={category} />
|
{absorptionSpeeds}
|
||||||
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
{categoryLabels}
|
||||||
<SelectTrigger>{category ? categoryLabels[category] : m["productForm_selectCategory"]()}</SelectTrigger>
|
{textureLabels}
|
||||||
<SelectContent>
|
{absorptionLabels}
|
||||||
{#each categories as cat}
|
bind:category
|
||||||
<SelectItem value={cat}>{categoryLabels[cat]}</SelectItem>
|
bind:recommendedTime
|
||||||
{/each}
|
bind:leaveOn
|
||||||
</SelectContent>
|
bind:texture
|
||||||
</Select>
|
bind:absorptionSpeed
|
||||||
</div>
|
/>
|
||||||
|
{/await}
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
|
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
|
||||||
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
|
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
|
||||||
|
|
@ -651,7 +544,7 @@
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>{m["productForm_recommendedFor"]()}</Label>
|
<Label>{m["productForm_recommendedFor"]()}</Label>
|
||||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<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">
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -675,7 +568,7 @@
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>{m["productForm_targetConcerns"]()}</Label>
|
<Label>{m["productForm_targetConcerns"]()}</Label>
|
||||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<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">
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -734,7 +627,7 @@
|
||||||
onclick={() => (activesPanelOpen = !activesPanelOpen)}
|
onclick={() => (activesPanelOpen = !activesPanelOpen)}
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium">{m["productForm_activeIngredients"]()}</span>
|
<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>
|
||||||
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
|
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -742,7 +635,7 @@
|
||||||
<input type="hidden" name="actives_json" value={activesJson} />
|
<input type="hidden" name="actives_json" value={activesJson} />
|
||||||
|
|
||||||
{#if activesPanelOpen}
|
{#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="rounded-md border border-border p-3 space-y-3">
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
|
|
@ -795,7 +688,7 @@
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
||||||
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
||||||
{#each ingFunctions as fn}
|
{#each ingFunctions as fn (fn)}
|
||||||
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
|
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -820,319 +713,59 @@
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
|
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
|
||||||
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
{#await import('$lib/components/product-form/ProductFormAssessmentSection.svelte') then mod}
|
||||||
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
|
{@const AssessmentSection = mod.default}
|
||||||
<CardContent>
|
<AssessmentSection
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
visible={editSection === 'assessment'}
|
||||||
{#each effectFields as field}
|
{selectClass}
|
||||||
{@const key = field.key as keyof typeof effectValues}
|
{effectFields}
|
||||||
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
|
bind:effectValues
|
||||||
<span class="text-xs text-muted-foreground">{field.label}</span>
|
{tristate}
|
||||||
<input
|
bind:ctxAfterShaving
|
||||||
type="range"
|
bind:ctxAfterAcids
|
||||||
name="effect_{field.key}"
|
bind:ctxAfterRetinoids
|
||||||
min="0"
|
bind:ctxCompromisedBarrier
|
||||||
max="5"
|
bind:ctxLowUvOnly
|
||||||
step="1"
|
bind:fragranceFree
|
||||||
bind:value={effectValues[key]}
|
bind:essentialOilsFree
|
||||||
class="accent-primary"
|
bind:alcoholDenatFree
|
||||||
/>
|
bind:pregnancySafe
|
||||||
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
|
/>
|
||||||
</div>
|
{/await}
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
{#await import('$lib/components/product-form/ProductFormDetailsSection.svelte') then mod}
|
||||||
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
{@const DetailsSection = mod.default}
|
||||||
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
<DetailsSection
|
||||||
<CardContent>
|
visible={editSection === 'details'}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{textareaClass}
|
||||||
<div class="space-y-2">
|
bind:priceAmount
|
||||||
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
|
bind:priceCurrency
|
||||||
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
|
bind:sizeMl
|
||||||
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}>
|
bind:fullWeightG
|
||||||
<SelectTrigger>{tristateLabel(ctxAfterShaving)}</SelectTrigger>
|
bind:emptyWeightG
|
||||||
<SelectContent>
|
bind:paoMonths
|
||||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
bind:phMin
|
||||||
</SelectContent>
|
bind:phMax
|
||||||
</Select>
|
bind:usageNotes
|
||||||
</div>
|
bind:minIntervalHours
|
||||||
|
bind:maxFrequencyPerWeek
|
||||||
|
bind:needleLengthMm
|
||||||
|
bind:isMedication
|
||||||
|
bind:isTool
|
||||||
|
{computedPriceLabel}
|
||||||
|
{computedPricePerUseLabel}
|
||||||
|
{computedPriceTierLabel}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
|
||||||
<div class="space-y-2">
|
{#await import('$lib/components/product-form/ProductFormNotesSection.svelte') then mod}
|
||||||
<Label>{m["productForm_ctxAfterAcids"]()}</Label>
|
{@const NotesSection = mod.default}
|
||||||
<input type="hidden" name="ctx_safe_after_acids" value={ctxAfterAcids} />
|
<NotesSection
|
||||||
<Select type="single" value={ctxAfterAcids} onValueChange={(v) => (ctxAfterAcids = v)}>
|
visible={editSection === 'notes'}
|
||||||
<SelectTrigger>{tristateLabel(ctxAfterAcids)}</SelectTrigger>
|
{selectClass}
|
||||||
<SelectContent>
|
{textareaClass}
|
||||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
{tristate}
|
||||||
</SelectContent>
|
bind:personalRepurchaseIntent
|
||||||
</Select>
|
bind:personalToleranceNotes
|
||||||
</div>
|
/>
|
||||||
|
{/await}
|
||||||
<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>
|
|
||||||
|
|
|
||||||
62
frontend/src/lib/components/ProductFormAiModal.svelte
Normal file
62
frontend/src/lib/components/ProductFormAiModal.svelte
Normal 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}
|
||||||
24
frontend/src/lib/components/forms/FormSectionCard.svelte
Normal file
24
frontend/src/lib/components/forms/FormSectionCard.svelte
Normal 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>
|
||||||
54
frontend/src/lib/components/forms/GroupedSelect.svelte
Normal file
54
frontend/src/lib/components/forms/GroupedSelect.svelte
Normal 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>
|
||||||
44
frontend/src/lib/components/forms/HintCheckbox.svelte
Normal file
44
frontend/src/lib/components/forms/HintCheckbox.svelte
Normal 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>
|
||||||
45
frontend/src/lib/components/forms/LabeledInputField.svelte
Normal file
45
frontend/src/lib/components/forms/LabeledInputField.svelte
Normal 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>
|
||||||
49
frontend/src/lib/components/forms/SimpleSelect.svelte
Normal file
49
frontend/src/lib/components/forms/SimpleSelect.svelte
Normal 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>
|
||||||
5
frontend/src/lib/components/forms/form-classes.ts
Normal file
5
frontend/src/lib/components/forms/form-classes.ts
Normal 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';
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -2,16 +2,16 @@
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
destructive:
|
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",
|
"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: {
|
defaultVariants: {
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
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",
|
"border-border bg-card text-card-foreground shadow-sm hover:border-[color:var(--page-accent)] hover:bg-[var(--page-accent-soft)]",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
secondary: "border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-[var(--page-accent-soft)]",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "border-transparent px-0 text-[var(--page-accent)] underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
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]",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
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]",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
|
const domainClass = $derived(getDomainClass(page.url.pathname));
|
||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
||||||
|
|
@ -40,18 +41,27 @@
|
||||||
);
|
);
|
||||||
return !moreSpecific;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col bg-background md:flex-row">
|
<div class="app-shell {domainClass}">
|
||||||
<!-- Mobile header -->
|
<!-- 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>
|
<div>
|
||||||
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
|
<span class="app-mobile-title">{m["nav_appName"]()}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
class="app-icon-button"
|
||||||
aria-label={m.common_toggleMenu()}
|
aria-label={m.common_toggleMenu()}
|
||||||
>
|
>
|
||||||
{#if mobileMenuOpen}
|
{#if mobileMenuOpen}
|
||||||
|
|
@ -73,14 +83,14 @@
|
||||||
></button>
|
></button>
|
||||||
<!-- Drawer (same z-50 but later in DOM, on top) -->
|
<!-- Drawer (same z-50 but later in DOM, on top) -->
|
||||||
<nav
|
<nav
|
||||||
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
|
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden app-sidebar"
|
||||||
>
|
>
|
||||||
<div class="mb-8 px-3">
|
<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>
|
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item (item.href)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|
@ -103,13 +113,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Desktop Sidebar -->
|
<!-- 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">
|
<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>
|
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item (item.href)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|
@ -130,7 +140,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 overflow-auto p-4 md:p-8">
|
<main class="app-main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,137 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
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();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const stateColors: Record<string, string> = {
|
const stateTone: Record<string, string> = {
|
||||||
excellent: 'bg-green-100 text-green-800',
|
excellent: 'state-pill state-pill--excellent',
|
||||||
good: 'bg-blue-100 text-blue-800',
|
good: 'state-pill state-pill--good',
|
||||||
fair: 'bg-yellow-100 text-yellow-800',
|
fair: 'state-pill state-pill--fair',
|
||||||
poor: 'bg-red-100 text-red-800'
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="editorial-dashboard">
|
||||||
<div>
|
<div class="editorial-atmosphere" aria-hidden="true"></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="grid gap-6 md:grid-cols-2">
|
<section class="editorial-hero reveal-1">
|
||||||
<!-- Latest skin snapshot -->
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<Card>
|
<h2 class="editorial-title">{m.dashboard_title()}</h2>
|
||||||
<CardHeader>
|
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p>
|
||||||
<CardTitle>{m["dashboard_latestSnapshot"]()}</CardTitle>
|
|
||||||
</CardHeader>
|
{#if data.latestSnapshot}
|
||||||
<CardContent>
|
{@const snapshot = data.latestSnapshot}
|
||||||
{#if data.latestSnapshot}
|
<div class="hero-strip">
|
||||||
{@const s = data.latestSnapshot}
|
<div>
|
||||||
<div class="space-y-3">
|
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p>
|
||||||
<div class="flex items-center justify-between">
|
<p class="hero-strip-value">{snapshot.snapshot_date}</p>
|
||||||
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
|
</div>
|
||||||
{#if s.overall_state}
|
{#if snapshot.overall_state}
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
|
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
|
||||||
{s.overall_state}
|
{humanize(snapshot.overall_state)}
|
||||||
</span>
|
</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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Recent routines -->
|
<div class="editorial-grid">
|
||||||
<Card>
|
<section class="editorial-panel reveal-2">
|
||||||
<CardHeader>
|
<header class="panel-header">
|
||||||
<CardTitle>{m["dashboard_recentRoutines"]()}</CardTitle>
|
<p class="panel-index">01</p>
|
||||||
</CardHeader>
|
<h3>{m["dashboard_latestSnapshot"]()}</h3>
|
||||||
<CardContent>
|
</header>
|
||||||
{#if data.recentRoutines.length}
|
<div class="panel-action-row">
|
||||||
<ul class="space-y-2">
|
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
|
||||||
{#each data.recentRoutines as routine (routine.id)}
|
</div>
|
||||||
<li class="flex items-center justify-between">
|
|
||||||
<a href="/routines/{routine.id}" class="text-sm hover:underline">
|
{#if data.latestSnapshot}
|
||||||
{routine.routine_date}
|
{@const s = data.latestSnapshot}
|
||||||
</a>
|
<div class="snapshot-meta-row">
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<span class="snapshot-date">{s.snapshot_date}</span>
|
||||||
{routine.part_of_day.toUpperCase()}
|
{#if s.overall_state}
|
||||||
</Badge>
|
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
|
||||||
</li>
|
{/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}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-muted-foreground">{m["dashboard_noRoutines"]()}</p>
|
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -21,68 +22,67 @@
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
|
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
|
||||||
const flagColors: Record<string, string> = {
|
const flagPills: Record<string, string> = {
|
||||||
N: 'bg-green-100 text-green-800',
|
N: 'health-flag-pill health-flag-pill--normal',
|
||||||
ABN: 'bg-red-100 text-red-800',
|
ABN: 'health-flag-pill health-flag-pill--abnormal',
|
||||||
POS: 'bg-orange-100 text-orange-800',
|
POS: 'health-flag-pill health-flag-pill--positive',
|
||||||
NEG: 'bg-blue-100 text-blue-800',
|
NEG: 'health-flag-pill health-flag-pill--negative',
|
||||||
L: 'bg-yellow-100 text-yellow-800',
|
L: 'health-flag-pill health-flag-pill--low',
|
||||||
H: 'bg-red-100 text-red-800'
|
H: 'health-flag-pill health-flag-pill--high'
|
||||||
};
|
};
|
||||||
|
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let selectedFlag = $state('');
|
let selectedFlag = $state('');
|
||||||
let filterFlag = $derived(data.flag ?? '');
|
let filterFlag = $derived(data.flag ?? '');
|
||||||
|
|
||||||
|
const flagOptions = flags.map((f) => ({ value: f, label: f }));
|
||||||
|
|
||||||
function onFlagChange(v: string) {
|
function onFlagChange(v: string) {
|
||||||
const base = resolve('/health/lab-results');
|
const base = resolve('/health/lab-results');
|
||||||
const url = v ? base + '?flag=' + v : base;
|
const target = v ? `${base}?flag=${encodeURIComponent(v)}` : base;
|
||||||
goto(url, { replaceState: true });
|
goto(target, { replaceState: true });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<section class="editorial-hero reveal-1">
|
||||||
<div>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["labResults_title"]()}</h2>
|
<h2 class="editorial-title">{m["labResults_title"]()}</h2>
|
||||||
<p class="text-muted-foreground">{m["labResults_count"]({ count: data.results.length })}</p>
|
<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>
|
</div>
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
</section>
|
||||||
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.created}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<!-- Filter -->
|
<!-- 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>
|
<span class="text-sm text-muted-foreground">{m["labResults_flagFilter"]()}</span>
|
||||||
<Select
|
<select
|
||||||
type="single"
|
class={`${baseSelectClass} w-32`}
|
||||||
value={filterFlag}
|
value={filterFlag}
|
||||||
onValueChange={onFlagChange}
|
onchange={(e) => onFlagChange(e.currentTarget.value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
|
<option value="">{m["labResults_flagAll"]()}</option>
|
||||||
<SelectContent>
|
{#each flags as f (f)}
|
||||||
<SelectItem value="">{m["labResults_flagAll"]()}</SelectItem>
|
<option value={f}>{f}</option>
|
||||||
{#each flags as f (f)}
|
{/each}
|
||||||
<SelectItem value={f}>{f}</SelectItem>
|
</select>
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<Card>
|
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
|
||||||
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
||||||
|
|
@ -108,29 +108,23 @@
|
||||||
<Label for="unit_original">{m["labResults_unit"]()}</Label>
|
<Label for="unit_original">{m["labResults_unit"]()}</Label>
|
||||||
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
|
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<SimpleSelect
|
||||||
<Label>{m["labResults_flag"]()}</Label>
|
id="flag"
|
||||||
<input type="hidden" name="flag" value={selectedFlag} />
|
name="flag"
|
||||||
<Select type="single" value={selectedFlag} onValueChange={(v) => (selectedFlag = v)}>
|
label={m["labResults_flag"]()}
|
||||||
<SelectTrigger>{selectedFlag || m["labResults_flagNone"]()}</SelectTrigger>
|
options={flagOptions}
|
||||||
<SelectContent>
|
placeholder={m["labResults_flagNone"]()}
|
||||||
<SelectItem value="">{m["labResults_flagNone"]()}</SelectItem>
|
bind:value={selectedFlag}
|
||||||
{#each flags as f (f)}
|
/>
|
||||||
<SelectItem value={f}>{f}</SelectItem>
|
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<Button type="submit">{m.common_add()}</Button>
|
<Button type="submit">{m.common_add()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</FormSectionCard>
|
||||||
</Card>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Desktop: table -->
|
<!-- Desktop: table -->
|
||||||
<div class="hidden rounded-md border border-border md:block">
|
<div class="products-table-shell hidden md:block reveal-2">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -159,7 +153,7 @@
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{#if r.flag}
|
{#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}
|
{r.flag}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -180,13 +174,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: cards -->
|
<!-- 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)}
|
{#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">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
|
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
|
||||||
{#if r.flag}
|
{#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}
|
{r.flag}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
|
@ -15,12 +16,12 @@
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let kind = $state('supplement');
|
let kind = $state('supplement');
|
||||||
|
|
||||||
const kindColors: Record<string, string> = {
|
const kindPills: Record<string, string> = {
|
||||||
prescription: 'bg-purple-100 text-purple-800',
|
prescription: 'health-kind-pill health-kind-pill--prescription',
|
||||||
otc: 'bg-blue-100 text-blue-800',
|
otc: 'health-kind-pill health-kind-pill--otc',
|
||||||
supplement: 'bg-green-100 text-green-800',
|
supplement: 'health-kind-pill health-kind-pill--supplement',
|
||||||
herbal: 'bg-emerald-100 text-emerald-800',
|
herbal: 'health-kind-pill health-kind-pill--herbal',
|
||||||
other: 'bg-gray-100 text-gray-700'
|
other: 'health-kind-pill health-kind-pill--other'
|
||||||
};
|
};
|
||||||
|
|
||||||
const kindLabels: Record<string, () => string> = {
|
const kindLabels: Record<string, () => string> = {
|
||||||
|
|
@ -30,44 +31,43 @@
|
||||||
herbal: m["medications_kindHerbal"],
|
herbal: m["medications_kindHerbal"],
|
||||||
other: m["medications_kindOther"]
|
other: m["medications_kindOther"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const kindOptions = $derived(kinds.map((k) => ({ value: k, label: kindLabels[k]?.() ?? k })));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<section class="editorial-hero reveal-1">
|
||||||
<div>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.medications_title()}</h2>
|
<h2 class="editorial-title">{m.medications_title()}</h2>
|
||||||
<p class="text-muted-foreground">{m.medications_count({ count: data.medications.length })}</p>
|
<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>
|
</div>
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
</section>
|
||||||
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.created}
|
{#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}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<Card>
|
<FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
|
||||||
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<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">
|
<div class="col-span-2">
|
||||||
<Label>{m.medications_kind()}</Label>
|
<SimpleSelect
|
||||||
<input type="hidden" name="kind" value={kind} />
|
id="kind"
|
||||||
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
|
name="kind"
|
||||||
<SelectTrigger>{kindLabels[kind]?.() ?? kind}</SelectTrigger>
|
label={m.medications_kind()}
|
||||||
<SelectContent>
|
options={kindOptions}
|
||||||
{#each kinds as k (k)}
|
bind:value={kind}
|
||||||
<SelectItem value={k}>{kindLabels[k]?.() ?? k}</SelectItem>
|
/>
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="product_name">{m["medications_productName"]()}</Label>
|
<Label for="product_name">{m["medications_productName"]()}</Label>
|
||||||
|
|
@ -85,16 +85,15 @@
|
||||||
<Button type="submit">{m.common_add()}</Button>
|
<Button type="submit">{m.common_add()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</FormSectionCard>
|
||||||
</Card>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="editorial-panel reveal-2 space-y-3">
|
||||||
{#each data.medications as med (med.record_id)}
|
{#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 justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<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}
|
{kindLabels[med.kind]?.() ?? med.kind}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-medium">{med.product_name}</span>
|
<span class="font-medium">{med.product_name}</span>
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTier(value?: string): string {
|
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 === 'budget') return m['productForm_priceBudget']();
|
||||||
if (value === 'mid') return m['productForm_priceMid']();
|
if (value === 'mid') return m['productForm_priceMid']();
|
||||||
if (value === 'premium') return m['productForm_pricePremium']();
|
if (value === 'premium') return m['productForm_pricePremium']();
|
||||||
|
|
@ -125,64 +125,69 @@
|
||||||
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
|
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCategory(value: string): string {
|
||||||
|
return value.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<section class="editorial-hero reveal-1">
|
||||||
<div>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
|
<h2 class="editorial-title">{m.products_title()}</h2>
|
||||||
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p>
|
||||||
</div>
|
<div class="editorial-toolbar">
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
|
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
|
||||||
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="editorial-panel reveal-2">
|
||||||
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
<div class="editorial-filter-row">
|
||||||
<Button
|
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
||||||
variant={ownershipFilter === f ? 'default' : 'outline'}
|
<Button
|
||||||
size="sm"
|
variant={ownershipFilter === f ? 'default' : 'outline'}
|
||||||
onclick={() => ownershipFilter = f}
|
size="sm"
|
||||||
>
|
onclick={() => ownershipFilter = f}
|
||||||
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
|
>
|
||||||
</Button>
|
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
|
||||||
{/each}
|
</Button>
|
||||||
</div>
|
{/each}
|
||||||
|
|
||||||
<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>
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
{m['products_colBrand']()}
|
<div class="w-full lg:max-w-md">
|
||||||
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
|
<Input
|
||||||
</Button>
|
type="search"
|
||||||
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
|
bind:value={searchQuery}
|
||||||
{m['products_colName']()}
|
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
|
||||||
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
|
/>
|
||||||
</Button>
|
</div>
|
||||||
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
|
<div class="flex flex-wrap gap-1">
|
||||||
{m['products_colTime']()}
|
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
|
||||||
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
|
{m['products_colBrand']()}
|
||||||
</Button>
|
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||||
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
|
</Button>
|
||||||
{m["products_colPricePerUse"]()}
|
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
|
||||||
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
|
{m['products_colName']()}
|
||||||
</Button>
|
{#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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: table -->
|
<!-- Desktop: table -->
|
||||||
<div class="hidden rounded-md border border-border md:block">
|
<div class="products-table-shell hidden md:block reveal-2">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -202,9 +207,9 @@
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groupedProducts as [category, products] (category)}
|
{#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">
|
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
|
||||||
{category.replace(/_/g, ' ')}
|
{formatCategory(category)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
|
|
@ -241,18 +246,18 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: cards -->
|
<!-- 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}
|
{#if totalCount === 0}
|
||||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
|
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groupedProducts as [category, products] (category)}
|
{#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">
|
<div class="products-section-title">
|
||||||
{category.replace(/_/g, ' ')}
|
{formatCategory(category)}
|
||||||
</div>
|
</div>
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/products/${product.id}`)}
|
href={resolve(`/products/${product.id}`)}
|
||||||
class={`block rounded-lg border border-border p-4 ${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="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTier(value?: string): string {
|
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 === 'budget') return m['productForm_priceBudget']();
|
||||||
if (value === 'mid') return m['productForm_priceMid']();
|
if (value === 'mid') return m['productForm_priceMid']();
|
||||||
if (value === 'premium') return m['productForm_pricePremium']();
|
if (value === 'premium') return m['productForm_pricePremium']();
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
|
|
||||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
<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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="product-edit-form"
|
form="product-edit-form"
|
||||||
|
|
@ -89,32 +89,29 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6 pb-20 md:pb-0">
|
<div class="editorial-page space-y-4 pb-20 md:pb-0">
|
||||||
<div class="space-y-2">
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
<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>
|
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
<div class="flex flex-wrap items-start gap-3">
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<div class="min-w-0 space-y-2">
|
<h2 class="break-words editorial-title">{product.name}</h2>
|
||||||
<h2 class="break-words text-2xl font-bold tracking-tight">{product.name}</h2>
|
<div class="products-meta-strip">
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<span class="font-medium text-foreground">{product.brand}</span>
|
||||||
<span class="font-medium text-foreground">{product.brand}</span>
|
<span>•</span>
|
||||||
<span>•</span>
|
<span class="uppercase">{product.recommended_time}</span>
|
||||||
<span class="uppercase">{product.recommended_time}</span>
|
<span>•</span>
|
||||||
<span>•</span>
|
<span>{product.category.replace(/_/g, ' ')}</span>
|
||||||
<span>{product.category.replace(/_/g, ' ')}</span>
|
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
||||||
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#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}
|
{/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">
|
<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 })}>
|
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
|
||||||
<Boxes class="size-4" aria-hidden="true" />
|
<Boxes class="size-4" aria-hidden="true" />
|
||||||
|
|
@ -136,13 +133,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.inventoryAdded}
|
{#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}
|
||||||
{#if form?.inventoryUpdated}
|
{#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}
|
||||||
{#if form?.inventoryDeleted}
|
{#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}
|
||||||
|
|
||||||
{#if showInventoryForm}
|
{#if showInventoryForm}
|
||||||
|
|
@ -334,7 +331,7 @@
|
||||||
<ProductForm
|
<ProductForm
|
||||||
bind:this={productFormRef}
|
bind:this={productFormRef}
|
||||||
{product}
|
{product}
|
||||||
bind:dirty={isEditDirty}
|
onDirtyChange={(dirty) => (isEditDirty = dirty)}
|
||||||
saveVersion={editSaveVersion}
|
saveVersion={editSaveVersion}
|
||||||
showAiTrigger={false}
|
showAiTrigger={false}
|
||||||
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
|
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,20 @@
|
||||||
|
|
||||||
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
<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>
|
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
<h2 class="editorial-title">{m["products_newTitle"]()}</h2>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#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}
|
{form.error}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 />
|
<ProductForm />
|
||||||
|
|
||||||
<div class="flex gap-3 pb-6">
|
<div class="flex gap-3 pb-6">
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,18 @@
|
||||||
|
|
||||||
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
<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>
|
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<Card>
|
<Card class="reveal-2">
|
||||||
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
|
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
|
||||||
|
|
@ -63,14 +64,14 @@
|
||||||
|
|
||||||
{#if suggestions && suggestions.length > 0}
|
{#if suggestions && suggestions.length > 0}
|
||||||
{#if reasoning}
|
{#if reasoning}
|
||||||
<Card class="border-muted bg-muted/30">
|
<Card class="border-muted bg-muted/30 reveal-3">
|
||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
|
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 reveal-3">
|
||||||
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
|
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
|
||||||
{#each suggestions as s (s.product_type)}
|
{#each suggestions as s (s.product_type)}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -22,28 +22,27 @@
|
||||||
|
|
||||||
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<section class="editorial-hero reveal-1">
|
||||||
<div>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
|
<h2 class="editorial-title">{m.routines_title()}</h2>
|
||||||
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
|
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
|
||||||
</div>
|
<div class="editorial-toolbar">
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
|
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
|
||||||
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{#if sortedDates.length}
|
{#if sortedDates.length}
|
||||||
<div class="space-y-4">
|
<div class="editorial-panel reveal-2 space-y-4">
|
||||||
{#each sortedDates as date}
|
{#each sortedDates as date (date)}
|
||||||
<div>
|
<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">
|
<div class="space-y-1">
|
||||||
{#each byDate[date] as routine}
|
{#each byDate[date] as routine (routine.id)}
|
||||||
<a
|
<a
|
||||||
href="/routines/{routine.id}"
|
href={resolve(`/routines/${routine.id}`)}
|
||||||
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors"
|
class="routine-ledger-row"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
|
|
@ -61,6 +60,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||||
import { updateRoutineStep } from '$lib/api';
|
import { updateRoutineStep } from '$lib/api';
|
||||||
import type { GroomingAction, RoutineStep } from '$lib/types';
|
import type { GroomingAction, RoutineStep } from '$lib/types';
|
||||||
|
|
@ -10,14 +11,8 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import {
|
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
|
||||||
Select,
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectGroupHeading,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger
|
|
||||||
} from '$lib/components/ui/select';
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
|
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
@ -137,21 +132,36 @@
|
||||||
.filter((c) => groups.has(c))
|
.filter((c) => groups.has(c))
|
||||||
.map((c) => [c, groups.get(c)!] as const);
|
.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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
<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="editorial-page space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
<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>
|
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<div class="flex items-center gap-3">
|
||||||
{routine.part_of_day.toUpperCase()}
|
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
|
||||||
</Badge>
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
</div>
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if routine.notes}
|
{#if routine.notes}
|
||||||
|
|
@ -159,7 +169,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Steps -->
|
<!-- Steps -->
|
||||||
<div class="space-y-3">
|
<div class="editorial-panel reveal-2 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
|
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
|
||||||
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
||||||
|
|
@ -172,29 +182,14 @@
|
||||||
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
||||||
<div class="space-y-1">
|
<GroupedSelect
|
||||||
<Label>{m.routines_product()}</Label>
|
id="new_step_product"
|
||||||
<input type="hidden" name="product_id" value={selectedProductId} />
|
name="product_id"
|
||||||
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
|
label={m.routines_product()}
|
||||||
<SelectTrigger>
|
groups={groupedProductOptions}
|
||||||
{#if selectedProductId}
|
placeholder={m["routines_selectProduct"]()}
|
||||||
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()}
|
bind:value={selectedProductId}
|
||||||
{: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>
|
|
||||||
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|
@ -226,32 +221,14 @@
|
||||||
<div class="px-4 py-3 space-y-3">
|
<div class="px-4 py-3 space-y-3">
|
||||||
{#if step.product_id !== undefined}
|
{#if step.product_id !== undefined}
|
||||||
<!-- Product step: change product / dose / region -->
|
<!-- Product step: change product / dose / region -->
|
||||||
<div class="space-y-1">
|
<GroupedSelect
|
||||||
<Label>{m.routines_product()}</Label>
|
id={`edit_step_product_${step.id}`}
|
||||||
<Select
|
label={m.routines_product()}
|
||||||
type="single"
|
groups={groupedProductOptions}
|
||||||
value={editDraft.product_id ?? ''}
|
placeholder={m["routines_selectProduct"]()}
|
||||||
onValueChange={(v) => (editDraft.product_id = v || undefined)}
|
value={editDraft.product_id ?? ''}
|
||||||
>
|
onChange={(value) => (editDraft.product_id = value || 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>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>{m.routines_dose()}</Label>
|
<Label>{m.routines_dose()}</Label>
|
||||||
|
|
@ -272,24 +249,15 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Action step: change action_type / notes -->
|
<!-- Action step: change action_type / notes -->
|
||||||
<div class="space-y-1">
|
<SimpleSelect
|
||||||
<Label>{m["routines_action"]()}</Label>
|
id={`edit_step_action_${step.id}`}
|
||||||
<Select
|
label={m["routines_action"]()}
|
||||||
type="single"
|
options={groomingActionOptions}
|
||||||
value={editDraft.action_type ?? ''}
|
placeholder={m["routines_selectAction"]()}
|
||||||
onValueChange={(v) =>
|
value={editDraft.action_type ?? ''}
|
||||||
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
onChange={(value) =>
|
||||||
>
|
(editDraft.action_type = (value || 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>
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>{m.routines_notes()}</Label>
|
<Label>{m.routines_notes()}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -362,7 +330,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator class="opacity-50" />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
|
@ -46,28 +47,31 @@
|
||||||
|
|
||||||
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
||||||
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2>
|
<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>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
||||||
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
|
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.created}
|
{#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}
|
||||||
{#if form?.updated}
|
{#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}
|
||||||
{#if form?.deleted}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<!-- Add form -->
|
<!-- Add form -->
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,51 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ArrowLeft } from 'lucide-svelte';
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let partOfDay = $state('am');
|
let partOfDay = $state('am');
|
||||||
|
|
||||||
|
const partOfDayOptions = [
|
||||||
|
{ value: 'am', label: m.common_am() },
|
||||||
|
{ value: 'pm', label: m.common_pm() }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="max-w-md space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
<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>
|
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Card>
|
<FormSectionCard title={m["routines_detailsTitle"]()} className="reveal-2">
|
||||||
<CardHeader><CardTitle>{m["routines_detailsTitle"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" use:enhance class="space-y-5">
|
<form method="POST" use:enhance class="space-y-5">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="routine_date">{m.routines_date()}</Label>
|
<Label for="routine_date">{m.routines_date()}</Label>
|
||||||
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
|
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<SimpleSelect
|
||||||
<Label>{m["routines_amOrPm"]()}</Label>
|
id="part_of_day"
|
||||||
<input type="hidden" name="part_of_day" value={partOfDay} />
|
name="part_of_day"
|
||||||
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
|
label={m["routines_amOrPm"]()}
|
||||||
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
options={partOfDayOptions}
|
||||||
<SelectContent>
|
bind:value={partOfDay}
|
||||||
<SelectItem value="am">{m.common_am()}</SelectItem>
|
/>
|
||||||
<SelectItem value="pm">{m.common_pm()}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="notes">{m.routines_notes()}</Label>
|
<Label for="notes">{m.routines_notes()}</Label>
|
||||||
|
|
@ -53,9 +54,8 @@
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<Button type="submit">{m["routines_createRoutine"]()}</Button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</FormSectionCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, 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 { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
|
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
|
|
@ -34,6 +37,13 @@
|
||||||
let loadingBatch = $state(false);
|
let loadingBatch = $state(false);
|
||||||
let loadingSave = $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 {
|
function stepLabel(step: SuggestedStep): string {
|
||||||
if (step.product_id && productMap[step.product_id]) {
|
if (step.product_id && productMap[step.product_id]) {
|
||||||
const p = productMap[step.product_id];
|
const p = productMap[step.product_id];
|
||||||
|
|
@ -104,17 +114,18 @@
|
||||||
|
|
||||||
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<section class="editorial-panel reveal-1 space-y-3">
|
||||||
<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>
|
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
<h2 class="editorial-title">{m.suggest_title()}</h2>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<Tabs value="single">
|
<Tabs value="single" class="reveal-2 editorial-tabs">
|
||||||
<TabsList class="w-full">
|
<TabsList class="w-full">
|
||||||
<TabsTrigger value="single" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_singleTab"]()}</TabsTrigger>
|
<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>
|
<TabsTrigger value="batch" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_batchTab"]()}</TabsTrigger>
|
||||||
|
|
@ -122,26 +133,20 @@
|
||||||
|
|
||||||
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
||||||
<TabsContent value="single" class="space-y-6 pt-4">
|
<TabsContent value="single" class="space-y-6 pt-4">
|
||||||
<Card>
|
<FormSectionCard title={m["suggest_singleParams"]()}>
|
||||||
<CardHeader><CardTitle class="text-base">{m["suggest_singleParams"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="single_date">{m.suggest_date()}</Label>
|
<Label for="single_date">{m.suggest_date()}</Label>
|
||||||
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<SimpleSelect
|
||||||
<Label>{m["suggest_timeOfDay"]()}</Label>
|
id="single_part_of_day"
|
||||||
<input type="hidden" name="part_of_day" value={partOfDay} />
|
name="part_of_day"
|
||||||
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}>
|
label={m["suggest_timeOfDay"]()}
|
||||||
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
options={partOfDayOptions}
|
||||||
<SelectContent>
|
bind:value={partOfDay}
|
||||||
<SelectItem value="am">{m["suggest_amMorning"]()}</SelectItem>
|
/>
|
||||||
<SelectItem value="pm">{m["suggest_pmEvening"]()}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -151,35 +156,23 @@
|
||||||
name="notes"
|
name="notes"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder={m["suggest_contextPlaceholder"]()}
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
{#if partOfDay === 'am'}
|
{#if partOfDay === 'am'}
|
||||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
<HintCheckbox
|
||||||
<input
|
|
||||||
id="single_leaving_home"
|
id="single_leaving_home"
|
||||||
name="leaving_home"
|
name="leaving_home"
|
||||||
type="checkbox"
|
label={m["suggest_leavingHomeLabel"]()}
|
||||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
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}
|
{/if}
|
||||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
<HintCheckbox
|
||||||
<input
|
id="single_include_minoxidil_beard"
|
||||||
id="single_include_minoxidil_beard"
|
name="include_minoxidil_beard"
|
||||||
name="include_minoxidil_beard"
|
label={m["suggest_minoxidilToggleLabel"]()}
|
||||||
type="checkbox"
|
hint={m["suggest_minoxidilToggleHint"]()}
|
||||||
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>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={loadingSingle} class="w-full">
|
<Button type="submit" disabled={loadingSingle} class="w-full">
|
||||||
{#if loadingSingle}
|
{#if loadingSingle}
|
||||||
|
|
@ -190,8 +183,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</FormSectionCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{#if suggestion}
|
{#if suggestion}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -272,9 +264,7 @@
|
||||||
|
|
||||||
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
|
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
|
||||||
<TabsContent value="batch" class="space-y-6 pt-4">
|
<TabsContent value="batch" class="space-y-6 pt-4">
|
||||||
<Card>
|
<FormSectionCard title={m["suggest_batchRange"]()}>
|
||||||
<CardHeader><CardTitle class="text-base">{m["suggest_batchRange"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
|
<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="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -294,33 +284,21 @@
|
||||||
name="notes"
|
name="notes"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder={m["suggest_batchContextPlaceholder"]()}
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
<HintCheckbox
|
||||||
<input
|
id="batch_include_minoxidil_beard"
|
||||||
id="batch_include_minoxidil_beard"
|
name="include_minoxidil_beard"
|
||||||
name="include_minoxidil_beard"
|
label={m["suggest_minoxidilToggleLabel"]()}
|
||||||
type="checkbox"
|
hint={m["suggest_minoxidilToggleHint"]()}
|
||||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
/>
|
||||||
/>
|
<HintCheckbox
|
||||||
<div class="space-y-0.5">
|
id="batch_minimize_products"
|
||||||
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
name="minimize_products"
|
||||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
label={m["suggest_minimizeProductsLabel"]()}
|
||||||
</div>
|
hint={m["suggest_minimizeProductsHint"]()}
|
||||||
</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>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={loadingBatch} class="w-full">
|
<Button type="submit" disabled={loadingBatch} class="w-full">
|
||||||
{#if loadingBatch}
|
{#if loadingBatch}
|
||||||
|
|
@ -331,8 +309,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</FormSectionCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{#if batch}
|
{#if batch}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
|
||||||
import { Sparkles, Pencil, X } from 'lucide-svelte';
|
import { Sparkles, Pencil, X } from 'lucide-svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
@ -18,11 +17,11 @@
|
||||||
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
||||||
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||||
|
|
||||||
const stateColors: Record<string, string> = {
|
const statePills: Record<string, string> = {
|
||||||
excellent: 'bg-green-100 text-green-800',
|
excellent: 'state-pill state-pill--excellent',
|
||||||
good: 'bg-blue-100 text-blue-800',
|
good: 'state-pill state-pill--good',
|
||||||
fair: 'bg-yellow-100 text-yellow-800',
|
fair: 'state-pill state-pill--fair',
|
||||||
poor: 'bg-red-100 text-red-800'
|
poor: 'state-pill state-pill--poor'
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateLabels: Record<string, () => string> = {
|
const stateLabels: Record<string, () => string> = {
|
||||||
|
|
@ -109,6 +108,11 @@
|
||||||
let aiLoading = $state(false);
|
let aiLoading = $state(false);
|
||||||
let aiError = $state('');
|
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(
|
const sortedSnapshots = $derived(
|
||||||
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
[...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:head><title>{m.skin_title()} — innercontext</title></svelte:head>
|
||||||
<svelte:window onkeydown={handleModalKeydown} />
|
<svelte:window onkeydown={handleModalKeydown} />
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="editorial-page space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<section class="editorial-hero reveal-1">
|
||||||
<div>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
|
<h2 class="editorial-title">{m.skin_title()}</h2>
|
||||||
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
|
<p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p>
|
||||||
</div>
|
<div class="editorial-toolbar">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
||||||
<Sparkles class="size-4" />
|
<Sparkles class="size-4" />
|
||||||
|
|
@ -185,19 +188,19 @@
|
||||||
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.created}
|
{#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}
|
||||||
{#if form?.updated}
|
{#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}
|
||||||
{#if form?.deleted}
|
{#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}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
|
@ -249,96 +252,109 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- New snapshot form -->
|
<!-- New snapshot form -->
|
||||||
<Card>
|
<Card class="reveal-2">
|
||||||
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-1">
|
<LabeledInputField
|
||||||
<Label for="snapshot_date">{m.skin_date()}</Label>
|
id="snapshot_date"
|
||||||
<Input
|
name="snapshot_date"
|
||||||
id="snapshot_date"
|
label={m.skin_date()}
|
||||||
name="snapshot_date"
|
type="date"
|
||||||
type="date"
|
required
|
||||||
bind:value={snapshotDate}
|
bind:value={snapshotDate}
|
||||||
required
|
/>
|
||||||
/>
|
<SimpleSelect
|
||||||
</div>
|
id="overall_state"
|
||||||
<div class="space-y-1">
|
name="overall_state"
|
||||||
<Label>{m["skin_overallState"]()}</Label>
|
label={m["skin_overallState"]()}
|
||||||
<input type="hidden" name="overall_state" value={overallState} />
|
options={overallStateOptions}
|
||||||
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
placeholder={m.common_select()}
|
||||||
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
|
bind:value={overallState}
|
||||||
<SelectContent>
|
/>
|
||||||
{#each states as s (s)}
|
<SimpleSelect
|
||||||
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
|
id="texture"
|
||||||
{/each}
|
name="texture"
|
||||||
</SelectContent>
|
label={m.skin_texture()}
|
||||||
</Select>
|
options={textureOptions}
|
||||||
</div>
|
placeholder={m.common_select()}
|
||||||
<div class="space-y-1">
|
bind:value={texture}
|
||||||
<Label>{m.skin_texture()}</Label>
|
/>
|
||||||
<input type="hidden" name="texture" value={texture} />
|
<SimpleSelect
|
||||||
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
id="skin_type"
|
||||||
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
|
name="skin_type"
|
||||||
<SelectContent>
|
label={m["skin_skinType"]()}
|
||||||
{#each skinTextures as t (t)}
|
options={skinTypeOptions}
|
||||||
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
|
placeholder={m.common_select()}
|
||||||
{/each}
|
bind:value={skinType}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<SimpleSelect
|
||||||
</div>
|
id="barrier_state"
|
||||||
<div class="space-y-1">
|
name="barrier_state"
|
||||||
<Label>{m["skin_skinType"]()}</Label>
|
label={m["skin_barrierState"]()}
|
||||||
<input type="hidden" name="skin_type" value={skinType} />
|
options={barrierOptions}
|
||||||
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
|
placeholder={m.common_select()}
|
||||||
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
|
bind:value={barrierState}
|
||||||
<SelectContent>
|
/>
|
||||||
{#each skinTypes as st (st)}
|
<LabeledInputField
|
||||||
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
|
id="hydration_level"
|
||||||
{/each}
|
name="hydration_level"
|
||||||
</SelectContent>
|
label={m.skin_hydration()}
|
||||||
</Select>
|
type="number"
|
||||||
</div>
|
min="1"
|
||||||
<div class="space-y-1">
|
max="5"
|
||||||
<Label>{m["skin_barrierState"]()}</Label>
|
bind:value={hydrationLevel}
|
||||||
<input type="hidden" name="barrier_state" value={barrierState} />
|
/>
|
||||||
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
<LabeledInputField
|
||||||
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
|
id="sensitivity_level"
|
||||||
<SelectContent>
|
name="sensitivity_level"
|
||||||
{#each barrierStates as b (b)}
|
label={m.skin_sensitivity()}
|
||||||
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
|
type="number"
|
||||||
{/each}
|
min="1"
|
||||||
</SelectContent>
|
max="5"
|
||||||
</Select>
|
bind:value={sensitivityLevel}
|
||||||
</div>
|
/>
|
||||||
<div class="space-y-1">
|
<LabeledInputField
|
||||||
<Label for="hydration_level">{m.skin_hydration()}</Label>
|
id="sebum_tzone"
|
||||||
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
|
name="sebum_tzone"
|
||||||
</div>
|
label={m["skin_sebumTzone"]()}
|
||||||
<div class="space-y-1">
|
type="number"
|
||||||
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
|
min="1"
|
||||||
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
|
max="5"
|
||||||
</div>
|
bind:value={sebumTzone}
|
||||||
<div class="space-y-1">
|
/>
|
||||||
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
|
<LabeledInputField
|
||||||
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
|
id="sebum_cheeks"
|
||||||
</div>
|
name="sebum_cheeks"
|
||||||
<div class="space-y-1">
|
label={m["skin_sebumCheeks"]()}
|
||||||
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
|
type="number"
|
||||||
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
|
min="1"
|
||||||
</div>
|
max="5"
|
||||||
<div class="space-y-1 col-span-2">
|
bind:value={sebumCheeks}
|
||||||
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
|
/>
|
||||||
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
|
<LabeledInputField
|
||||||
</div>
|
id="active_concerns"
|
||||||
<div class="space-y-1 col-span-2">
|
name="active_concerns"
|
||||||
<Label for="priorities">{m["skin_priorities"]()}</Label>
|
label={m["skin_activeConcerns"]()}
|
||||||
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
|
placeholder={m["skin_activeConcernsPlaceholder"]()}
|
||||||
</div>
|
className="space-y-1 col-span-2"
|
||||||
<div class="space-y-1 col-span-2">
|
bind:value={activeConcernsRaw}
|
||||||
<Label for="notes">{m.skin_notes()}</Label>
|
/>
|
||||||
<Input id="notes" name="notes" bind:value={notes} />
|
<LabeledInputField
|
||||||
</div>
|
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">
|
<div class="col-span-2">
|
||||||
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
|
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -347,7 +363,7 @@
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 reveal-2">
|
||||||
{#each sortedSnapshots as snap (snap.id)}
|
{#each sortedSnapshots as snap (snap.id)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
|
|
@ -363,86 +379,105 @@
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={snap.id} />
|
<input type="hidden" name="id" value={snap.id} />
|
||||||
<div class="space-y-1">
|
<LabeledInputField
|
||||||
<Label for="edit_snapshot_date">{m.skin_date()}</Label>
|
id="edit_snapshot_date"
|
||||||
<Input id="edit_snapshot_date" name="snapshot_date" type="date" bind:value={editSnapshotDate} required />
|
name="snapshot_date"
|
||||||
</div>
|
label={m.skin_date()}
|
||||||
<div class="space-y-1">
|
type="date"
|
||||||
<Label>{m["skin_overallState"]()}</Label>
|
required
|
||||||
<input type="hidden" name="overall_state" value={editOverallState} />
|
bind:value={editSnapshotDate}
|
||||||
<Select type="single" value={editOverallState} onValueChange={(v) => (editOverallState = v)}>
|
/>
|
||||||
<SelectTrigger>{editOverallState ? stateLabels[editOverallState]?.() ?? editOverallState : m.common_select()}</SelectTrigger>
|
<SimpleSelect
|
||||||
<SelectContent>
|
id="edit_overall_state"
|
||||||
{#each states as s (s)}
|
name="overall_state"
|
||||||
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
|
label={m["skin_overallState"]()}
|
||||||
{/each}
|
options={overallStateOptions}
|
||||||
</SelectContent>
|
placeholder={m.common_select()}
|
||||||
</Select>
|
bind:value={editOverallState}
|
||||||
</div>
|
/>
|
||||||
<div class="space-y-1">
|
<SimpleSelect
|
||||||
<Label>{m.skin_texture()}</Label>
|
id="edit_texture"
|
||||||
<input type="hidden" name="texture" value={editTexture} />
|
name="texture"
|
||||||
<Select type="single" value={editTexture} onValueChange={(v) => (editTexture = v)}>
|
label={m.skin_texture()}
|
||||||
<SelectTrigger>{editTexture ? textureLabels[editTexture]?.() ?? editTexture : m.common_select()}</SelectTrigger>
|
options={textureOptions}
|
||||||
<SelectContent>
|
placeholder={m.common_select()}
|
||||||
{#each skinTextures as t (t)}
|
bind:value={editTexture}
|
||||||
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
|
/>
|
||||||
{/each}
|
<SimpleSelect
|
||||||
</SelectContent>
|
id="edit_skin_type"
|
||||||
</Select>
|
name="skin_type"
|
||||||
</div>
|
label={m["skin_skinType"]()}
|
||||||
<div class="space-y-1">
|
options={skinTypeOptions}
|
||||||
<Label>{m["skin_skinType"]()}</Label>
|
placeholder={m.common_select()}
|
||||||
<input type="hidden" name="skin_type" value={editSkinType} />
|
bind:value={editSkinType}
|
||||||
<Select type="single" value={editSkinType} onValueChange={(v) => (editSkinType = v)}>
|
/>
|
||||||
<SelectTrigger>{editSkinType ? skinTypeLabels[editSkinType]?.() ?? editSkinType : m.common_select()}</SelectTrigger>
|
<SimpleSelect
|
||||||
<SelectContent>
|
id="edit_barrier_state"
|
||||||
{#each skinTypes as st (st)}
|
name="barrier_state"
|
||||||
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
|
label={m["skin_barrierState"]()}
|
||||||
{/each}
|
options={barrierOptions}
|
||||||
</SelectContent>
|
placeholder={m.common_select()}
|
||||||
</Select>
|
bind:value={editBarrierState}
|
||||||
</div>
|
/>
|
||||||
<div class="space-y-1">
|
<LabeledInputField
|
||||||
<Label>{m["skin_barrierState"]()}</Label>
|
id="edit_hydration_level"
|
||||||
<input type="hidden" name="barrier_state" value={editBarrierState} />
|
name="hydration_level"
|
||||||
<Select type="single" value={editBarrierState} onValueChange={(v) => (editBarrierState = v)}>
|
label={m.skin_hydration()}
|
||||||
<SelectTrigger>{editBarrierState ? barrierLabels[editBarrierState]?.() ?? editBarrierState : m.common_select()}</SelectTrigger>
|
type="number"
|
||||||
<SelectContent>
|
min="1"
|
||||||
{#each barrierStates as b (b)}
|
max="5"
|
||||||
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
|
bind:value={editHydrationLevel}
|
||||||
{/each}
|
/>
|
||||||
</SelectContent>
|
<LabeledInputField
|
||||||
</Select>
|
id="edit_sensitivity_level"
|
||||||
</div>
|
name="sensitivity_level"
|
||||||
<div class="space-y-1">
|
label={m.skin_sensitivity()}
|
||||||
<Label for="edit_hydration_level">{m.skin_hydration()}</Label>
|
type="number"
|
||||||
<Input id="edit_hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={editHydrationLevel} />
|
min="1"
|
||||||
</div>
|
max="5"
|
||||||
<div class="space-y-1">
|
bind:value={editSensitivityLevel}
|
||||||
<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} />
|
<LabeledInputField
|
||||||
</div>
|
id="edit_sebum_tzone"
|
||||||
<div class="space-y-1">
|
name="sebum_tzone"
|
||||||
<Label for="edit_sebum_tzone">{m["skin_sebumTzone"]()}</Label>
|
label={m["skin_sebumTzone"]()}
|
||||||
<Input id="edit_sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={editSebumTzone} />
|
type="number"
|
||||||
</div>
|
min="1"
|
||||||
<div class="space-y-1">
|
max="5"
|
||||||
<Label for="edit_sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
|
bind:value={editSebumTzone}
|
||||||
<Input id="edit_sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={editSebumCheeks} />
|
/>
|
||||||
</div>
|
<LabeledInputField
|
||||||
<div class="space-y-1 col-span-2">
|
id="edit_sebum_cheeks"
|
||||||
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
|
name="sebum_cheeks"
|
||||||
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
|
label={m["skin_sebumCheeks"]()}
|
||||||
</div>
|
type="number"
|
||||||
<div class="space-y-1 col-span-2">
|
min="1"
|
||||||
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
|
max="5"
|
||||||
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
|
bind:value={editSebumCheeks}
|
||||||
</div>
|
/>
|
||||||
<div class="space-y-1 col-span-2">
|
<LabeledInputField
|
||||||
<Label for="edit_notes">{m.skin_notes()}</Label>
|
id="edit_active_concerns"
|
||||||
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
name="active_concerns"
|
||||||
</div>
|
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">
|
<div class="col-span-2 flex gap-2">
|
||||||
<Button type="submit">{m.common_save()}</Button>
|
<Button type="submit">{m.common_save()}</Button>
|
||||||
<Button type="button" variant="outline" onclick={() => (editingId = null)}>{m.common_cancel()}</Button>
|
<Button type="button" variant="outline" onclick={() => (editingId = null)}>{m.common_cancel()}</Button>
|
||||||
|
|
@ -463,10 +498,10 @@
|
||||||
</div>
|
</div>
|
||||||
{#if snap.overall_state || snap.texture}
|
{#if snap.overall_state || snap.texture}
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
{#if snap.overall_state}
|
{#if snap.overall_state}
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
<span class={statePills[snap.overall_state] ?? 'state-pill'}>
|
||||||
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.texture}
|
{#if snap.texture}
|
||||||
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import type { Plugin, Rollup } from 'vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||||
|
|
||||||
const stripDeprecatedRollupOptions = {
|
const stripDeprecatedRollupOptions: Plugin = {
|
||||||
name: 'strip-deprecated-rollup-options',
|
name: 'strip-deprecated-rollup-options',
|
||||||
outputOptions(options: Record<string, unknown>) {
|
outputOptions(options: Rollup.OutputOptions) {
|
||||||
if ('codeSplitting' in options) {
|
const nextOptions = { ...options } as Rollup.OutputOptions & { codeSplitting?: unknown };
|
||||||
delete options.codeSplitting;
|
if ('codeSplitting' in nextOptions) {
|
||||||
|
delete nextOptions.codeSplitting;
|
||||||
}
|
}
|
||||||
return options;
|
return nextOptions;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue