feat(profile): add profile settings and LLM user context
This commit is contained in:
parent
db3d9514d5
commit
b99b9ed68e
25 changed files with 472 additions and 9 deletions
|
|
@ -36,6 +36,7 @@
|
|||
--accent-products: hsl(95 28% 33%);
|
||||
--accent-routines: hsl(186 27% 33%);
|
||||
--accent-skin: hsl(16 51% 44%);
|
||||
--accent-profile: hsl(198 29% 35%);
|
||||
--accent-health-labs: hsl(212 41% 39%);
|
||||
--accent-health-meds: hsl(140 31% 33%);
|
||||
|
||||
|
|
@ -136,6 +137,11 @@ body {
|
|||
--page-accent-soft: hsl(20 52% 88%);
|
||||
}
|
||||
|
||||
.domain-profile {
|
||||
--page-accent: var(--accent-profile);
|
||||
--page-accent-soft: hsl(196 30% 89%);
|
||||
}
|
||||
|
||||
.domain-health-labs {
|
||||
--page-accent: var(--accent-health-labs);
|
||||
--page-accent-soft: hsl(208 38% 88%);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
RoutineSuggestion,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot,
|
||||
UserProfile,
|
||||
} from "./types";
|
||||
|
||||
// ─── Core fetch helpers ──────────────────────────────────────────────────────
|
||||
|
|
@ -47,6 +48,14 @@ export const api = {
|
|||
del: (path: string) => request<void>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getProfile = (): Promise<UserProfile | null> => api.get("/profile");
|
||||
|
||||
export const updateProfile = (
|
||||
body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" },
|
||||
): Promise<UserProfile> => api.patch("/profile", body);
|
||||
|
||||
// ─── Products ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductListParams {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export type SkinConcern =
|
|||
| "hair_growth"
|
||||
| "sebum_excess";
|
||||
export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy";
|
||||
export type SexAtBirth = "male" | "female" | "intersex";
|
||||
export type SkinType =
|
||||
| "dry"
|
||||
| "oily"
|
||||
|
|
@ -348,3 +349,11 @@ export interface SkinConditionSnapshot {
|
|||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
birth_date?: string;
|
||||
sex_at_birth?: SexAtBirth;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
Pill,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-svelte';
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
|
||||
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
|
||||
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles },
|
||||
{ href: resolve('/profile'), label: m.nav_profile(), icon: UserRound },
|
||||
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
|
||||
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }
|
||||
]);
|
||||
|
|
@ -46,6 +48,7 @@
|
|||
if (pathname.startsWith('/products')) return 'domain-products';
|
||||
if (pathname.startsWith('/routines')) return 'domain-routines';
|
||||
if (pathname.startsWith('/skin')) return 'domain-skin';
|
||||
if (pathname.startsWith('/profile')) return 'domain-profile';
|
||||
if (pathname.startsWith('/health/lab-results')) return 'domain-health-labs';
|
||||
if (pathname.startsWith('/health/medications')) return 'domain-health-meds';
|
||||
return 'domain-dashboard';
|
||||
|
|
|
|||
29
frontend/src/routes/profile/+page.server.ts
Normal file
29
frontend/src/routes/profile/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { getProfile, updateProfile } from '$lib/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const profile = await getProfile();
|
||||
return { profile };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const birth_date_raw = String(form.get('birth_date') ?? '').trim();
|
||||
const sex_at_birth_raw = String(form.get('sex_at_birth') ?? '').trim();
|
||||
|
||||
const payload: { birth_date?: string; sex_at_birth?: 'male' | 'female' | 'intersex' } = {};
|
||||
if (birth_date_raw) payload.birth_date = birth_date_raw;
|
||||
if (sex_at_birth_raw === 'male' || sex_at_birth_raw === 'female' || sex_at_birth_raw === 'intersex') {
|
||||
payload.sex_at_birth = sex_at_birth_raw;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await updateProfile(payload);
|
||||
return { saved: true, profile };
|
||||
} catch (e) {
|
||||
return fail(502, { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
};
|
||||
62
frontend/src/routes/profile/+page.svelte
Normal file
62
frontend/src/routes/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let birthDate = $state(untrack(() => data.profile?.birth_date ?? ''));
|
||||
let sexAtBirth = $state(untrack(() => data.profile?.sex_at_birth ?? ''));
|
||||
|
||||
const sexOptions = $derived([
|
||||
{ value: 'female', label: m.profile_sexFemale() },
|
||||
{ value: 'male', label: m.profile_sexMale() },
|
||||
{ value: 'intersex', label: m.profile_sexIntersex() }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m.profile_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.profile_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.profile_subtitle()}</p>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.saved}
|
||||
<div class="editorial-alert editorial-alert--success">{m.profile_saved()}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/save" use:enhance class="reveal-2 space-y-4">
|
||||
<FormSectionCard title={m.profile_sectionBasic()} contentClassName="space-y-4">
|
||||
<LabeledInputField
|
||||
id="birth_date"
|
||||
name="birth_date"
|
||||
label={m.profile_birthDate()}
|
||||
type="date"
|
||||
bind:value={birthDate}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="sex_at_birth"
|
||||
name="sex_at_birth"
|
||||
label={m.profile_sexAtBirth()}
|
||||
options={sexOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={sexAtBirth}
|
||||
/>
|
||||
</FormSectionCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit">{m.common_save()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue